feat: add performance launch experience
This commit is contained in:
parent
d57502c153
commit
a362e7311f
|
|
@ -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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue