feat: add performance launch experience

This commit is contained in:
Developer 2026-09-05 08:42:54 -04:00
parent d57502c153
commit a362e7311f
6 changed files with 2169 additions and 46 deletions

View File

@ -25,6 +25,7 @@ import '../features/group_rides/group_ride_detail_screen.dart';
import '../features/group_rides/live_group_ride_screen.dart';
import '../features/routes/route_planner_screen.dart';
import '../features/routes/route_detail_screen.dart';
import '../features/performance/performance_launch_screen.dart';
// ignore: unused_element
final _rootNavigatorKey = GlobalKey<NavigatorState>();
@ -215,5 +216,9 @@ final appRouter = GoRouter(
return RouteDetailScreen(routeId: id);
},
),
GoRoute(
path: '/launch',
builder: (context, state) => const PerformanceLaunchScreen(),
),
],
);

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../performance/performance_timer_service.dart';
@ -23,28 +24,31 @@ class PerformanceSummary extends ConsumerWidget {
final elapsed = perfData.currentElapsedSeconds ?? 0.0;
final targetStr = perfData.target == PerformanceTarget.sixty ? '0-60' : '0-100';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.telemetryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.telemetryGreen, width: 1.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14),
const SizedBox(width: 5),
Text(
'$targetStr: ${elapsed.toStringAsFixed(2)}s',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w800,
color: AppColors.telemetryGreen,
return GestureDetector(
onTap: () => context.push('/launch'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.telemetryGreen.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.telemetryGreen, width: 1.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.timer, color: AppColors.telemetryGreen, size: 14),
const SizedBox(width: 5),
Text(
'$targetStr: ${elapsed.toStringAsFixed(2)}s',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w800,
color: AppColors.telemetryGreen,
),
),
),
],
],
),
),
);
}
@ -54,31 +58,34 @@ class PerformanceSummary extends ConsumerWidget {
}
final targetStr = lastResult.target == PerformanceTarget.sixty ? '0-60' : '0-100';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? AppColors.darkOutline : AppColors.lightOutline,
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber),
const SizedBox(width: 4),
Text(
'$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white70 : AppColors.lightOnSurface,
),
return GestureDetector(
onTap: () => context.push('/launch'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: isDark ? AppColors.darkSurfaceContainer : AppColors.lightSurfaceContainer,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark ? AppColors.darkOutline : AppColors.lightOutline,
width: 0.8,
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.emoji_events_outlined, size: 13, color: AppColors.warningAmber),
const SizedBox(width: 4),
Text(
'$targetStr: ${lastResult.timeSeconds.toStringAsFixed(2)}s',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white70 : AppColors.lightOnSurface,
),
),
],
),
),
);
}

File diff suppressed because it is too large Load Diff

View File

@ -69,6 +69,8 @@ class PerformanceLaunchStateMachine extends StateNotifier<PerformanceLaunchAttem
mode: mode,
target: target,
armedAtMonotonicMs: armedMs,
currentSpeedMph: _lastSample?.speed ?? 0.0,
telemetryConfidence: _lastSample?.quality?.overallConfidence ?? 1.0,
rideId: rideId,
motorcycleId: motorcycleId,
);

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/app_typography.dart';
import '../../core/theme/theme_extensions.dart';
import 'records_service.dart';
@ -236,6 +238,30 @@ class RecordsScreen extends ConsumerWidget {
),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 42,
child: OutlinedButton.icon(
key: const ValueKey('btn_open_launch_mode'),
icon: Icon(Icons.rocket_launch, size: 16, color: colors.launchGreen),
label: Text(
'PERFORMANCE LAUNCH MODE',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
color: colors.launchGreen,
),
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.launchGreen, width: 1.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () => context.push('/launch'),
),
),
],
),
);

View File

@ -0,0 +1,637 @@
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('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('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));
// 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);
// 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);
});
});
}