681 lines
29 KiB
Dart
681 lines
29 KiB
Dart
import 'dart:async';
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:motorcycle_dash/core/theme/theme.dart';
|
||
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
|
||
import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart';
|
||
import 'package:motorcycle_dash/features/performance/performance_launch_screen.dart';
|
||
import 'package:motorcycle_dash/features/performance/performance_providers.dart';
|
||
import 'package:motorcycle_dash/features/performance/services/monotonic_clock.dart';
|
||
import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.dart';
|
||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
||
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
||
void main() {
|
||
TestWidgetsFlutterBinding.ensureInitialized();
|
||
|
||
late AppDatabase db;
|
||
late StreamController<TelemetrySample> telemetryController;
|
||
late FakeMonotonicClock clock;
|
||
late RecordsService recordsService;
|
||
late SharedPreferences prefs;
|
||
late PerformanceLaunchStateMachine stateMachine;
|
||
late ProviderContainer container;
|
||
|
||
setUp(() async {
|
||
SharedPreferences.setMockInitialValues({});
|
||
prefs = await SharedPreferences.getInstance();
|
||
recordsService = RecordsService(prefs);
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
telemetryController = StreamController<TelemetrySample>.broadcast();
|
||
clock = FakeMonotonicClock(
|
||
initialElapsedMs: 10000,
|
||
initialDateTime: DateTime.utc(2026, 9, 5, 12, 0, 0),
|
||
);
|
||
stateMachine = PerformanceLaunchStateMachine(
|
||
db: db,
|
||
telemetryStream: telemetryController.stream,
|
||
recordsService: recordsService,
|
||
clock: clock,
|
||
countdownDurationMs: 3000,
|
||
);
|
||
container = ProviderContainer(
|
||
overrides: [
|
||
databaseProvider.overrideWithValue(db),
|
||
monotonicClockProvider.overrideWithValue(clock),
|
||
performanceLaunchProvider.overrideWith((ref) => stateMachine),
|
||
],
|
||
);
|
||
});
|
||
|
||
tearDown(() async {
|
||
container.dispose();
|
||
await telemetryController.close();
|
||
await db.close();
|
||
});
|
||
|
||
TelemetrySample makeSample({
|
||
required double speed,
|
||
int? monotonicMs,
|
||
DateTime? timestamp,
|
||
int? seq,
|
||
ValidationStatus status = ValidationStatus.valid,
|
||
double confidence = 1.0,
|
||
}) {
|
||
final ms = monotonicMs ?? clock.elapsedMilliseconds;
|
||
final dt = timestamp ?? clock.now;
|
||
return TelemetrySample(
|
||
timestamp: dt,
|
||
monotonicTimestampMs: ms,
|
||
sequenceNumber: seq,
|
||
speed: speed,
|
||
quality: TelemetryQuality(
|
||
status: status,
|
||
overallConfidence: confidence,
|
||
speedConfidence: confidence,
|
||
gpsConfidence: confidence,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget createTestWidget({
|
||
ThemeData? theme,
|
||
Size size = const Size(393, 852), // Pixel 8a default
|
||
double textScaleFactor = 1.0,
|
||
}) {
|
||
return UncontrolledProviderScope(
|
||
container: container,
|
||
child: MediaQuery(
|
||
data: MediaQueryData(
|
||
size: size,
|
||
textScaler: TextScaler.linear(textScaleFactor),
|
||
),
|
||
child: MaterialApp(
|
||
theme: theme ?? AppTheme.darkTheme,
|
||
home: const PerformanceLaunchScreen(),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
group('PLM-2: Setup Experience Tests', () {
|
||
testWidgets('1. Auto/Countdown selector renders and 2. 60/100 selector renders', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(find.text('Auto Launch'), findsOneWidget);
|
||
expect(find.text('Countdown'), findsOneWidget);
|
||
expect(find.text('0–60 MPH'), findsOneWidget);
|
||
expect(find.text('0–100 MPH'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('BUG-009: Renders cleanly without clipping on 800x360 landscape phone', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(tester.takeException(), isNull);
|
||
expect(find.byKey(const ValueKey('launch_mode_auto')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_mode_countdown')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_target_60')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_target_100')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(find.text('Auto Launch'), findsOneWidget);
|
||
expect(find.text('Countdown'), findsOneWidget);
|
||
expect(find.text('0–60 MPH'), findsOneWidget);
|
||
expect(find.text('0–100 MPH'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('3. ARM command uses selected configuration and 4. no local fake READY state', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
// Tap Countdown mode and 0-100 target
|
||
await tester.tap(find.byKey(const ValueKey('launch_mode_countdown')));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byKey(const ValueKey('launch_target_100')));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Tap ARM
|
||
await tester.tap(find.byKey(const ValueKey('btn_arm_launch')));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Since vehicle has no telemetry yet, state should NOT be READY
|
||
expect(stateMachine.state.mode, PerformanceLaunchMode.countdown);
|
||
expect(stateMachine.state.target, PerformanceLaunchTarget.hundred);
|
||
expect(stateMachine.state.state, PerformanceLaunchState.idle);
|
||
|
||
// Verify truthful waiting state renders (no fake ready)
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsNothing);
|
||
expect(find.byKey(const ValueKey('waiting_status_text')), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: READY Experience Tests', () {
|
||
testWidgets('5. waiting state renders truthfully with moving speed & transitions to READY when stopped', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
// Emit sample moving at 15 MPH
|
||
telemetryController.add(makeSample(speed: 15.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Arm while moving
|
||
await tester.tap(find.byKey(const ValueKey('btn_arm_launch')));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Should display COME TO A COMPLETE STOP
|
||
expect(find.text('COME TO A COMPLETE STOP'), findsOneWidget);
|
||
|
||
// Now bring motorcycle to a stop (< 1.0 MPH) with valid telemetry
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Transitions to READY!
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
expect(find.text('READY'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('6. Auto READY says accelerate when ready and no countdown button', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
expect(find.text('Accelerate when ready'), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('btn_start_countdown')), findsNothing);
|
||
});
|
||
|
||
testWidgets('7. Countdown READY exposes legitimate countdown start button', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
|
||
expect(find.text('Tap below to initiate countdown'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('BUG-010: Countdown READY renders without 68px overflow on 800x360 landscape phone', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(tester.takeException(), isNull);
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('btn_start_countdown')), findsOneWidget);
|
||
expect(find.text('Start Countdown'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('8. READY survives widget rebuild', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 1.0));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
|
||
// Trigger rebuild
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360))); // landscape resize
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsOneWidget);
|
||
expect(stateMachine.state.state, PerformanceLaunchState.ready);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Countdown Tests', () {
|
||
testWidgets('9. 3/2/1/GO derives from authoritative monotonic state & 10. widget animation is not timing authority', (tester) async {
|
||
clock.setElapsedMs(1000);
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Start countdown: scheduledGo = 1000 + 3000 = 4000
|
||
await tester.tap(find.byKey(const ValueKey('btn_start_countdown')));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// At t=1000ms: diff is 3000ms -> shows '3'
|
||
expect(find.text('3'), findsOneWidget);
|
||
|
||
// Advance monotonic clock to t=2500ms: diff is 1500ms -> shows '2'
|
||
clock.setElapsedMs(2500);
|
||
await tester.pump(const Duration(milliseconds: 40));
|
||
expect(find.text('2'), findsOneWidget);
|
||
|
||
// Advance monotonic clock to t=3500ms: diff is 500ms -> shows '1'
|
||
clock.setElapsedMs(3500);
|
||
await tester.pump(const Duration(milliseconds: 40));
|
||
expect(find.text('1'), findsOneWidget);
|
||
|
||
// Advance monotonic clock to t=4100ms (past scheduled GO): shows 'GO!'
|
||
clock.setElapsedMs(4100);
|
||
await tester.pump(const Duration(milliseconds: 40));
|
||
expect(find.text('GO!'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('11. rebuild does not restart countdown and 12. orientation/viewport resize preserves schedule', (tester) async {
|
||
clock.setElapsedMs(1000);
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
await tester.tap(find.byKey(const ValueKey('btn_start_countdown')));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
final scheduledGo = stateMachine.state.scheduledGoAtMonotonicMs;
|
||
expect(scheduledGo, 4000);
|
||
|
||
// Advance time to t=2500 (showing 2)
|
||
clock.setElapsedMs(2500);
|
||
await tester.pump(const Duration(milliseconds: 40));
|
||
expect(find.text('2'), findsOneWidget);
|
||
|
||
// Rotate / resize to landscape
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||
await tester.pump(const Duration(milliseconds: 40));
|
||
|
||
// Still at t=2500, still shows 2, scheduledGo unchanged!
|
||
expect(find.text('2'), findsOneWidget);
|
||
expect(stateMachine.state.scheduledGoAtMonotonicMs, scheduledGo);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Launched Timing Experience Tests', () {
|
||
testWidgets('13. active timing view requires no interaction, 14. speed renders, 15. timer renders', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Launch!
|
||
telemetryController.add(makeSample(speed: 12.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Launched state verified
|
||
expect(stateMachine.state.state, PerformanceLaunchState.launched);
|
||
expect(find.byKey(const ValueKey('speed_readout')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('elapsed_timer_readout')), findsOneWidget);
|
||
|
||
// Update speed and time at 20 Hz
|
||
telemetryController.add(makeSample(speed: 45.0, monotonicMs: 2500));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
expect(find.text('45'), findsOneWidget);
|
||
expect(find.text('1.45 s'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('16. 100 target displays authoritative 60 split when available', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.hundred);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Launch
|
||
telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Cross 60 MPH at t=3550ms
|
||
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3550));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('split_60_readout')), findsOneWidget);
|
||
expect(find.text('60 MPH SPLIT: 2.50 s'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('17. 60 target does not invent intermediate split', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
telemetryController.add(makeSample(speed: 40.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
expect(find.byKey(const ValueKey('split_60_readout')), findsNothing);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Result Presentation Tests', () {
|
||
testWidgets('18. Auto result omits Launch Reaction', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4250));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.completed);
|
||
expect(find.text('ACCELERATION'), findsOneWidget);
|
||
expect(find.text('3.20 s'), findsOneWidget);
|
||
// Launch reaction is NOT shown for Auto
|
||
expect(find.byKey(const ValueKey('launch_reaction_readout')), findsNothing);
|
||
expect(find.text('Launch Reaction'), findsNothing);
|
||
});
|
||
|
||
testWidgets('19. Countdown result displays Launch Reaction and 20. Total From GO', (tester) async {
|
||
clock.setElapsedMs(1000);
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Start countdown (scheduledGo = 4000)
|
||
await tester.tap(find.byKey(const ValueKey('btn_start_countdown')));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Rider moves at t=4250ms (+250ms reaction)
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 4250));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Cross 60 at t=7450ms (3.20s acceleration, 3.45s from GO)
|
||
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 7450));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.completed);
|
||
expect(find.byKey(const ValueKey('launch_reaction_readout')), findsOneWidget);
|
||
expect(find.text('+0.25 s'), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('total_from_go_readout')), findsOneWidget);
|
||
expect(find.text('3.45 s'), findsOneWidget);
|
||
expect(find.text('Total From GO'), findsOneWidget);
|
||
});
|
||
|
||
testWidgets('21. false start displays negative Launch Reaction, 22. labeled, 23. not PR', (tester) async {
|
||
clock.setElapsedMs(1000);
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Start countdown (scheduledGo = 4000)
|
||
await tester.tap(find.byKey(const ValueKey('btn_start_countdown')));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Rider jumps before GO at t=3750ms (-250ms false start)
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 3750));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
// Timing continues actively despite false start
|
||
expect(stateMachine.state.state, PerformanceLaunchState.launched);
|
||
expect(stateMachine.state.isFalseStart, isTrue);
|
||
expect(find.byKey(const ValueKey('speed_readout')), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('elapsed_timer_readout')), findsOneWidget);
|
||
|
||
// Interim sample while timing
|
||
telemetryController.add(makeSample(speed: 35.0, monotonicMs: 5000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
expect(stateMachine.state.state, PerformanceLaunchState.launched);
|
||
|
||
// Reaches 60 at t=6950ms
|
||
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 6950));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.completed);
|
||
expect(stateMachine.state.lastResult!.isFalseStart, isTrue);
|
||
expect(stateMachine.state.lastResult!.accelerationTimeSec, closeTo(3.20, 0.05));
|
||
|
||
// Verify false start badge and negative reaction
|
||
expect(find.text('FALSE START'), findsOneWidget);
|
||
expect(find.text('Movement detected before GO.'), findsOneWidget);
|
||
expect(find.byKey(const ValueKey('launch_reaction_readout')), findsOneWidget);
|
||
expect(find.text('-0.25 s'), findsOneWidget);
|
||
|
||
// Verify records service was not updated
|
||
expect(recordsService.state.fastest0To60, double.infinity);
|
||
});
|
||
|
||
testWidgets('24. Try Again invokes authoritative reset', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 4000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const ValueKey('btn_try_again')), findsOneWidget);
|
||
|
||
// Tap Try Again
|
||
await tester.tap(find.byKey(const ValueKey('btn_try_again')));
|
||
await tester.pumpAndSettle();
|
||
|
||
// State machine reset back to idle/setup
|
||
expect(stateMachine.state.state, PerformanceLaunchState.idle);
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Telemetry Unavailable Tests', () {
|
||
testWidgets('26. unavailable telemetry does not display fake READY & 27. rejected telemetry represented truthfully', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
// Arm auto launch
|
||
await tester.tap(find.byKey(const ValueKey('btn_arm_launch')));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Emit low confidence sample
|
||
telemetryController.add(makeSample(speed: 0.0, confidence: 0.2));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Does NOT show READY
|
||
expect(find.byKey(const ValueKey('ready_status_badge')), findsNothing);
|
||
expect(find.text('WAITING FOR VALID TELEMETRY'), findsOneWidget);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Responsive & Theme Tests', () {
|
||
testWidgets('28. no RenderFlex overflow at 320dp width', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(320, 600)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('29. Pixel-like portrait layout renders clean', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(412, 915)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('30. short landscape phone layout renders clean', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('31. standard landscape layout renders clean', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(915, 412)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('32. expanded tablet layout renders clean', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(1200, 800)));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('34. text scaling smoke test (1.5x scale)', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(textScaleFactor: 1.5));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('35. System/Light/Dark theme smoke test', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(theme: AppTheme.lightTheme));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
|
||
await tester.pumpWidget(createTestWidget(theme: AppTheme.darkTheme));
|
||
await tester.pumpAndSettle();
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
});
|
||
|
||
group('PLM-2: Lifecycle & Orientation State Preservation Tests', () {
|
||
testWidgets('36. resize does not recreate authoritative attempt & 37. rebuild does not duplicate persistence', (tester) async {
|
||
await tester.pumpWidget(createTestWidget(size: const Size(412, 915)));
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.completed);
|
||
|
||
// Resize/rebuild multiple times
|
||
await tester.pumpWidget(createTestWidget(size: const Size(800, 360)));
|
||
await tester.pumpAndSettle();
|
||
await tester.pumpWidget(createTestWidget(size: const Size(320, 600)));
|
||
await tester.pumpAndSettle();
|
||
|
||
// Only ONE row in performanceRuns table
|
||
await tester.runAsync(() async {
|
||
final runs = await db.select(db.performanceRuns).get();
|
||
expect(runs, hasLength(1));
|
||
});
|
||
});
|
||
|
||
testWidgets('25. Done resets state machine safely', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 4000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_done')), findsOneWidget);
|
||
await tester.tap(find.byKey(const ValueKey('btn_done')));
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.idle);
|
||
});
|
||
|
||
testWidgets('33. IME insets do not break setup screen', (tester) async {
|
||
await tester.pumpWidget(
|
||
UncontrolledProviderScope(
|
||
container: container,
|
||
child: MediaQuery(
|
||
data: const MediaQueryData(
|
||
size: Size(393, 852),
|
||
viewInsets: EdgeInsets.only(bottom: 350), // IME open
|
||
),
|
||
child: MaterialApp(
|
||
theme: AppTheme.darkTheme,
|
||
home: const PerformanceLaunchScreen(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
await tester.pumpAndSettle();
|
||
|
||
expect(find.byKey(const ValueKey('btn_arm_launch')), findsOneWidget);
|
||
expect(tester.takeException(), isNull);
|
||
});
|
||
|
||
testWidgets('38. concurrent arm calls during active launch do not corrupt attempt', (tester) async {
|
||
await tester.pumpWidget(createTestWidget());
|
||
await tester.pumpAndSettle();
|
||
|
||
stateMachine.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1050));
|
||
await tester.pump(const Duration(milliseconds: 20));
|
||
|
||
expect(stateMachine.state.state, PerformanceLaunchState.launched);
|
||
final initialArmedMs = stateMachine.state.armedAtMonotonicMs;
|
||
|
||
// Attempt concurrent re-arm during launched state
|
||
stateMachine.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.hundred);
|
||
expect(stateMachine.state.state, PerformanceLaunchState.launched);
|
||
expect(stateMachine.state.mode, PerformanceLaunchMode.auto);
|
||
expect(stateMachine.state.target, PerformanceLaunchTarget.sixty);
|
||
expect(stateMachine.state.armedAtMonotonicMs, initialArmedMs);
|
||
});
|
||
});
|
||
}
|