import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/performance/models/performance_launch_models.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/performance/performance_timer_service.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/sensor_fusion_service.dart'; import 'package:motorcycle_dash/services/compass_service.dart'; import 'package:motorcycle_dash/services/audio_alert_service.dart'; import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample, FieldTestSummary, FieldTestEvent; import 'package:drift/native.dart'; class MockAudioAlertService extends Fake implements AudioAlertService { @override Future speak(String text, {bool force = false}) async {} } class FakeTelemetryService extends TelemetryService { final StreamController _controller = StreamController.broadcast(); FakeTelemetryService( super.loc, super.fusion, super.compass, super.logger, super.validator, ); @override Stream get telemetryStream => _controller.stream; void emitSample(TelemetrySample sample) => _controller.add(sample); @override void dispose() => _controller.close(); } class _NullLocationService extends Fake implements LocationService { @override void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic; @override Stream getFilteredPositionStream() => const Stream.empty(); } class _NullSensorFusionService extends Fake implements SensorFusionService { @override Stream get leanAngleStream => const Stream.empty(); @override Stream get pitchAngleStream => const Stream.empty(); @override Stream get gForceStream => const Stream.empty(); @override double get latestRollRateDegPerSec => 0.0; @override double get latestRawAccelX => 0.0; @override double get latestRawAccelY => 0.0; @override double get latestRawAccelZ => 0.0; @override double get latestRawGyroX => 0.0; @override double get latestRawGyroY => 0.0; @override double get latestRawGyroZ => 0.0; } class _NullCompassService extends Fake implements CompassService { @override Stream get headingStream => const Stream.empty(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late FieldTestLogger logger; late FakeTelemetryService fakeTelemetry; late MockAudioAlertService mockAudio; late TelemetryValidator validator; late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = AppDatabase.forTesting(NativeDatabase.memory()); logger = FieldTestLogger(db); validator = TelemetryValidator(); fakeTelemetry = FakeTelemetryService( _NullLocationService(), _NullSensorFusionService(), _NullCompassService(), logger, validator, ); mockAudio = MockAudioAlertService(); }); tearDown(() async { fakeTelemetry.dispose(); logger.dispose(); await db.close(); }); ProviderContainer buildContainer() { final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), databaseProvider.overrideWithValue(db), telemetryServiceProvider.overrideWithValue(fakeTelemetry), audioAlertServiceProvider.overrideWithValue(mockAudio), fieldTestLoggerProvider.overrideWithValue(logger), ], ); addTearDown(container.dispose); return container; } final baseTime = DateTime(2026, 9, 19, 12, 0, 0); TelemetrySample createSample({ required int monotonicMs, DateTime? gpsTimestamp, int? gpsAgeMs, DateTime? imuTimestamp, double speed = 35.0, double lat = 34.0, double lon = -85.0, double? altitude, double? leanAngle, double? longitudinalG, double? gpsAccuracy = 10.0, }) { return TelemetrySample( timestamp: baseTime.add(Duration(milliseconds: monotonicMs)), monotonicTimestampMs: monotonicMs, latitude: lat, longitude: lon, altitude: altitude, speed: speed, gpsTimestamp: gpsTimestamp, gpsAgeMs: gpsAgeMs, imuTimestamp: imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)), leanAngle: leanAngle ?? 0.0, longitudinalG: longitudinalG ?? 0.0, gpsAccuracy: gpsAccuracy, quality: const TelemetryQuality( status: ValidationStatus.valid, overallConfidence: 1.0, ), ); } group('Phase 1F — GNSS-Derived Metrics Integrity During Stale GPS', () { test('1. Moving vehicle enters GPS outage: moving time stops accumulating after stale boundary', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); final gpsFixTime = baseTime; // 100 samples at 20 Hz (5 seconds). First 4s are fresh, last 1s is stale (> 4000ms). for (int i = 0; i <= 100; i++) { final monoMs = i * 50; final sampleTime = baseTime.add(Duration(milliseconds: monoMs)); final ageMs = sampleTime.difference(gpsFixTime).inMilliseconds; fakeTelemetry.emitSample(createSample( monotonicMs: monoMs, gpsTimestamp: gpsFixTime, gpsAgeMs: ageMs, speed: 40.0, )); } await Future.delayed(const Duration(milliseconds: 50)); final state = container.read(rideStateMachineProvider); // Fresh duration was 4.0s (80 frames of 50ms). Stale frames after 4000ms should NOT accumulate. expect(state.movingTimeSeconds, inInclusiveRange(3, 4)); // Moving time must NOT have reached 5 seconds expect(state.movingTimeSeconds, lessThan(5)); }); test('2. Moving vehicle enters GPS outage: trip distance stops accumulating', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); final gpsFixTime = baseTime; // First sample fakeTelemetry.emitSample(createSample( monotonicMs: 0, gpsTimestamp: gpsFixTime, gpsAgeMs: 0, speed: 40.0, lat: 34.0000, lon: -85.0000, )); // Advance 1 second with advancing coordinates while fresh for (int i = 1; i <= 20; i++) { fakeTelemetry.emitSample(createSample( monotonicMs: i * 50, gpsTimestamp: gpsFixTime, gpsAgeMs: i * 50, speed: 40.0, lat: 34.0000 + (i * 0.0001), lon: -85.0000, )); } await Future.delayed(const Duration(milliseconds: 30)); final distanceFresh = container.read(rideStateMachineProvider).tripDistanceMiles; expect(distanceFresh, greaterThan(0.0)); // Now GPS fix stops advancing. We enter stale period (> 4000ms). // Even if coordinates somehow changed in a stale sample, distance must NOT accumulate. for (int i = 81; i <= 120; i++) { fakeTelemetry.emitSample(createSample( monotonicMs: i * 50, gpsTimestamp: gpsFixTime, gpsAgeMs: i * 50, // 4050ms to 6000ms -> STALE speed: 40.0, lat: 34.0000 + (i * 0.0001), lon: -85.0000, )); } await Future.delayed(const Duration(milliseconds: 30)); final distanceAfterStale = container.read(rideStateMachineProvider).tripDistanceMiles; expect(distanceAfterStale, equals(distanceFresh)); }); test('3. Moving vehicle enters GPS outage: top speed does not update on stale samples', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); // Fresh sample at 50 MPH fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 0, speed: 50.0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0); // Stale sample with higher speed (85 MPH) but stale GPS (> 4000ms) fakeTelemetry.emitSample(createSample( monotonicMs: 6000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 5000, // 5000ms > 4000ms -> STALE speed: 85.0, )); await Future.delayed(const Duration(milliseconds: 20)); // Top speed must remain 50.0 MPH, NOT 85.0 MPH expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0); }); test('4. Boundary condition at 3999ms: sample is fresh, metrics accumulate', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); final gpsFixTime = baseTime; // Sample at 3950ms fakeTelemetry.emitSample(createSample( monotonicMs: 3950, gpsTimestamp: gpsFixTime, gpsAgeMs: 3950, speed: 45.0, )); // Sample at 3999ms (age 3999ms <= 4000ms -> FRESH) fakeTelemetry.emitSample(createSample( monotonicMs: 3999, gpsTimestamp: gpsFixTime, gpsAgeMs: 3999, speed: 65.0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(container.read(rideStateMachineProvider).topSpeedMph, 65.0); }); test('5. Boundary condition at 4000ms: sample is fresh, metrics accumulate', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); final gpsFixTime = baseTime; // Sample at 4000ms (age exactly 4000ms -> FRESH) fakeTelemetry.emitSample(createSample( monotonicMs: 4000, gpsTimestamp: gpsFixTime, gpsAgeMs: 4000, speed: 70.0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(container.read(rideStateMachineProvider).topSpeedMph, 70.0); }); test('6. Boundary condition at 4001ms: sample is stale, metrics do NOT accumulate', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); final gpsFixTime = baseTime; // Baseline sample at 4000ms fakeTelemetry.emitSample(createSample( monotonicMs: 4000, gpsTimestamp: gpsFixTime, gpsAgeMs: 4000, speed: 55.0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0); // Boundary sample at 4001ms (age 4001ms > 4000ms -> STALE) fakeTelemetry.emitSample(createSample( monotonicMs: 4001, gpsTimestamp: gpsFixTime, gpsAgeMs: 4001, speed: 95.0, )); await Future.delayed(const Duration(milliseconds: 20)); // Stale speed must NOT update top speed expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0); }); test('7. GNSS recovery after outage: distance does NOT jump across outage', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); // Fix 1 at t=0 fakeTelemetry.emitSample(createSample( monotonicMs: 0, gpsTimestamp: baseTime, gpsAgeMs: 0, speed: 30.0, lat: 34.0000, lon: -85.0000, )); // Fix 1 frame at t=50ms fakeTelemetry.emitSample(createSample( monotonicMs: 50, gpsTimestamp: baseTime, gpsAgeMs: 50, speed: 30.0, lat: 34.0001, lon: -85.0000, )); await Future.delayed(const Duration(milliseconds: 30)); final distanceBeforeOutage = container.read(rideStateMachineProvider).tripDistanceMiles; expect(distanceBeforeOutage, greaterThan(0.0)); // Outage: 10 seconds of stale samples at old coordinate for (int i = 81; i <= 200; i++) { fakeTelemetry.emitSample(createSample( monotonicMs: i * 50, gpsTimestamp: baseTime, gpsAgeMs: i * 50, // 4050ms to 10000ms -> STALE speed: 30.0, lat: 34.0001, lon: -85.0000, )); } await Future.delayed(const Duration(milliseconds: 30)); final distanceDuringOutage = container.read(rideStateMachineProvider).tripDistanceMiles; expect(distanceDuringOutage, equals(distanceBeforeOutage)); // RECOVERY: Frame N (first fresh fix 100m away) final recoveryFixTime = baseTime.add(const Duration(milliseconds: 10050)); fakeTelemetry.emitSample(createSample( monotonicMs: 10050, gpsTimestamp: recoveryFixTime, gpsAgeMs: 0, speed: 30.0, lat: 34.0010, // ~100m jump lon: -85.0000, )); await Future.delayed(const Duration(milliseconds: 30)); final distanceAtRecoveryFrame = container.read(rideStateMachineProvider).tripDistanceMiles; // Recovery frame MUST NOT integrate the 100m gap across the outage! expect(distanceAtRecoveryFrame, equals(distanceBeforeOutage)); // Frame N+1: Next fresh sample 50ms later (real motion from recovery coordinate) fakeTelemetry.emitSample(createSample( monotonicMs: 10100, gpsTimestamp: recoveryFixTime.add(const Duration(milliseconds: 50)), gpsAgeMs: 0, speed: 30.0, lat: 34.00101, // ~1m move lon: -85.0000, )); await Future.delayed(const Duration(milliseconds: 30)); final distanceAfterRecovery = container.read(rideStateMachineProvider).tripDistanceMiles; // Normal accumulation resumes without the artificial 100m catch-up jump! expect(distanceAfterRecovery, greaterThan(distanceBeforeOutage)); // Change should be tiny (~1 meter), NOT ~100 meters final deltaMiles = distanceAfterRecovery - distanceBeforeOutage; expect(deltaMiles, lessThan(0.01)); // 0.01 miles is 16 meters }); test('8. Dynamic motorcycle metrics: cannot update max lean / G during GPS outage', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); // Fresh sample while riding normally fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 0, speed: 30.0, leanAngle: 15.0, longitudinalG: 0.2, )); await Future.delayed(const Duration(milliseconds: 20)); expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0); expect(container.read(rideStateMachineProvider).maxAccelG, 0.2); // Bike enters garage / tunnel: GPS fix stops. // Stale sample (> 4000ms) with cached speed (30.0 MPH) and extreme kickstand lean (45 deg) fakeTelemetry.emitSample(createSample( monotonicMs: 6000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 5000, speed: 30.0, // cached raw speed leanAngle: 45.0, // kickstand lean longitudinalG: 1.5, // dropped phone / shake )); await Future.delayed(const Duration(milliseconds: 20)); // Stale speed must NOT satisfy dynamic moving gate; records must remain unchanged expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0); expect(container.read(rideStateMachineProvider).maxAccelG, 0.2); }); test('9. High Speed event: not triggered repeatedly during GPS outage', () async { final container = buildContainer(); final sm = container.read(rideStateMachineProvider.notifier); await sm.startRide(); // Fresh sample at 105 MPH -> triggers High Speed event fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 0, speed: 105.0, )); await Future.delayed(const Duration(milliseconds: 50)); final eventsInitial = await db.select(db.rideEvents).get(); expect(eventsInitial, hasLength(1)); expect(eventsInitial.first.eventType, 'High Speed'); // 10 seconds later, GPS is stale (> 4000ms), cached speed still 105 MPH fakeTelemetry.emitSample(createSample( monotonicMs: 11000, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 10000, speed: 105.0, )); await Future.delayed(const Duration(milliseconds: 50)); final eventsAfterStale = await db.select(db.rideEvents).get(); // Must NOT add another High Speed event from stale speed! expect(eventsAfterStale, hasLength(1)); }); }); group('Phase 1F — PerformanceLaunchStateMachine Stale GPS Gating', () { late StreamController perfController; late FakeMonotonicClock clock; PerformanceLaunchStateMachine? perfSm; setUp(() { perfController = StreamController.broadcast(); clock = FakeMonotonicClock( initialElapsedMs: 10000, initialDateTime: baseTime, ); }); tearDown(() async { perfSm?.dispose(); await perfController.close(); }); test('10. PerformanceLaunchStateMachine in launched: aborts with gpsLost when GPS becomes stale', () async { perfSm = PerformanceLaunchStateMachine( db: db, telemetryStream: perfController.stream, clock: clock, ); perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); // Stationary fresh sample -> READY perfController.add(TelemetrySample( timestamp: baseTime, monotonicTimestampMs: 1000, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(perfSm!.state.state, PerformanceLaunchState.ready); // Launch with fresh sample -> LAUNCHED perfController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 100)), monotonicTimestampMs: 1100, speed: 5.0, gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)), gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(perfSm!.state.state, PerformanceLaunchState.launched); // Accelerating fresh sample perfController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 1000)), monotonicTimestampMs: 2000, speed: 40.0, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(perfSm!.state.state, PerformanceLaunchState.launched); // GPS stops advancing. Sample arrives with stale GPS (> 4000ms) perfController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 6000)), monotonicTimestampMs: 7000, speed: 40.0, gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)), gpsAgeMs: 5000, // STALE )); await Future.delayed(const Duration(milliseconds: 20)); // MUST abort with gpsLost! expect(perfSm!.state.state, PerformanceLaunchState.aborted); expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost); }); test('11. PerformanceLaunchStateMachine in ready: does not launch from stale speed', () async { perfSm = PerformanceLaunchStateMachine( db: db, telemetryStream: perfController.stream, clock: clock, ); perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty); // Stationary fresh sample -> READY perfController.add(TelemetrySample( timestamp: baseTime, monotonicTimestampMs: 1000, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(perfSm!.state.state, PerformanceLaunchState.ready); // Stale sample with speed 15 MPH (> 1.0 MPH movement threshold) arrives perfController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 5000)), monotonicTimestampMs: 6000, speed: 15.0, gpsTimestamp: baseTime, gpsAgeMs: 5000, // STALE )); await Future.delayed(const Duration(milliseconds: 20)); // Must NOT launch! Should revert to idle on stale GPS expect(perfSm!.state.state, isNot(PerformanceLaunchState.launched)); expect(perfSm!.state.state, PerformanceLaunchState.idle); }); test('12. PerformanceLaunchStateMachine in countdown: aborts with gpsLost when GPS becomes stale', () async { perfSm = PerformanceLaunchStateMachine( db: db, telemetryStream: perfController.stream, clock: clock, ); perfSm!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty); // Stationary fresh sample -> READY perfController.add(TelemetrySample( timestamp: baseTime, monotonicTimestampMs: 1000, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(perfSm!.state.state, PerformanceLaunchState.ready); // Start countdown perfSm!.startCountdown(); expect(perfSm!.state.state, PerformanceLaunchState.countdown); // Stale sample arrives during countdown perfController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 5000)), monotonicTimestampMs: 6000, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 5000, // STALE )); await Future.delayed(const Duration(milliseconds: 20)); // Must abort with gpsLost! expect(perfSm!.state.state, PerformanceLaunchState.aborted); expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost); }); }); group('Phase 1F — PerformanceTimerService Stale GPS Gating', () { late StreamController timerController; PerformanceTimerService? timerSm; setUp(() { timerController = StreamController.broadcast(); }); tearDown(() async { timerSm?.dispose(); await timerController.close(); }); test('13. PerformanceTimerService in timing: aborts when GPS becomes stale', () async { timerSm = PerformanceTimerService( db, timerController.stream, ); timerSm!.setTarget(PerformanceTarget.sixty); // Stationary fresh sample -> READY timerController.add(TelemetrySample( timestamp: baseTime, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(timerSm!.state.state, PerformanceState.ready); // Launch with fresh sample -> TIMING timerController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 100)), speed: 5.0, gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)), gpsAgeMs: 0, )); await Future.delayed(const Duration(milliseconds: 20)); expect(timerSm!.state.state, PerformanceState.timing); // Stale sample arrives (> 4000ms) timerController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 6000)), speed: 40.0, gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)), gpsAgeMs: 5900, // STALE )); await Future.delayed(const Duration(milliseconds: 20)); // Must abort! expect(timerSm!.state.state, PerformanceState.aborted); // And no run should be saved in the database final runs = await db.select(db.performanceRuns).get(); expect(runs, isEmpty); }); }); }