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/models/recording_health_status.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/features/records/records_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(); const tickWait = Duration(milliseconds: 3300); const evidenceStaleMs = RecordingHealthContract.evidenceStaleAfterMs; // 4000ms 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, }) { return TelemetrySample( timestamp: baseTime.add(Duration(milliseconds: monotonicMs)), monotonicTimestampMs: monotonicMs, latitude: 34.0, longitude: -85.0, speed: speed, gpsTimestamp: gpsTimestamp, gpsAgeMs: gpsAgeMs, imuTimestamp: imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)), leanAngle: 0.0, pitchAngle: 0.0, longitudinalG: 0.0, lateralG: 0.0, ); } Future reachHealthyActive(ProviderContainer container) async { final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); // Initial evidence at monotonic 1000ms final t1 = baseTime; fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: t1, gpsAgeMs: 0, )); await pumpEventQueue(times: 20); // Wait for periodic batch write timer (3.0s) to commit to DB await Future.delayed(tickWait); await pumpEventQueue(times: 20); // Re-establish sensor freshness final t2 = baseTime.add(tickWait); fakeTelemetry.emitSample(createSample( monotonicMs: 1000 + tickWait.inMilliseconds, gpsTimestamp: t2, gpsAgeMs: 0, )); await pumpEventQueue(times: 20); expect(container.read(rideStateMachineProvider).healthStatus, RecordingHealthStatus.recordingActive); } group('Phase 1D: Stale-GPS Freshness Semantics', () { test('A. Duplicate GPS timestamp does NOT refresh GPS evidence', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final gpsT1 = baseTime; // Sample 1: first GPS fix at M1 = 1000 fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: gpsT1, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); // Subsequent samples at M2 = 2000, M3 = 3000, M4 = 5500 all carry same gpsT1 fakeTelemetry.emitSample(createSample( monotonicMs: 2000, gpsTimestamp: gpsT1, gpsAgeMs: 1000, )); await pumpEventQueue(times: 10); // Still within 4000ms evidence window from M1=1000 (age is 1000ms) expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); // At M4 = 5500, elapsed monotonic time since M1 (1000) is 4500ms > evidenceStaleAfterMs (4000) // Because gpsT1 was repeated, GPS evidence was NOT refreshed. fakeTelemetry.emitSample(createSample( monotonicMs: 5500, gpsTimestamp: gpsT1, gpsAgeMs: 4500, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse, reason: 'Duplicate GPS timestamp must not refresh GPS evidence; evidence must be stale'); }); test('B. Genuine newer GPS timestamp refreshes GPS evidence', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final gpsT1 = baseTime; final gpsT2 = baseTime.add(const Duration(seconds: 4)); // Sample 1: GPS T1 at M1 = 1000 fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: gpsT1, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); // Sample 2: Genuine newer GPS T2 at M2 = 5000 (4000ms later) fakeTelemetry.emitSample(createSample( monotonicMs: 5000, gpsTimestamp: gpsT2, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); // Sample 3 at M3 = 7000 (age relative to M2 is 2000ms <= 4000ms) fakeTelemetry.emitSample(createSample( monotonicMs: 7000, gpsTimestamp: gpsT2, gpsAgeMs: 2000, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue, reason: 'T2 refreshed GPS evidence at M2=5000, so at M3=7000 it is only 2000ms old and still fresh'); }); test('C. Older/out-of-order GPS timestamp does not refresh GPS evidence', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final gpsT1 = baseTime; final gpsT2 = baseTime.add(const Duration(seconds: 2)); // Accept T2 at M1 = 1000 fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: gpsT2, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); // Out-of-order older sample carrying T1 (< T2) arrives at M2 = 5500 fakeTelemetry.emitSample(createSample( monotonicMs: 5500, gpsTimestamp: gpsT1, gpsAgeMs: 4500, )); await pumpEventQueue(times: 10); // Monotonic age from M1(1000) to M2(5500) is 4500ms > 4000ms. // Older T1 must not refresh GPS evidence. expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse, reason: 'Older out-of-order GPS timestamp must not refresh GPS evidence'); }); test('D. Null GPS timestamp does not refresh GPS evidence', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final gpsT1 = baseTime; // Sample with GPS at M1 = 1000 fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: gpsT1, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); // Sample without GPS at M2 = 5500 fakeTelemetry.emitSample(createSample( monotonicMs: 5500, gpsTimestamp: null, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse, reason: 'Null GPS timestamp must not refresh GPS evidence'); }); test('E. Normal 10 Hz / 20 Hz operation maintains healthy GPS evidence', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); // Simulate 10 GPS fixes (100ms apart), each sampled twice at 20 Hz (50ms interval) int monotonic = 1000; int totalEmitted = 0; for (int fix = 0; fix < 10; fix++) { final fixTime = baseTime.add(Duration(milliseconds: fix * 100)); // First sample for this fix (0ms age) fakeTelemetry.emitSample(createSample( monotonicMs: monotonic, gpsTimestamp: fixTime, gpsAgeMs: 0, )); monotonic += 50; totalEmitted++; // Second sample for this fix (repackaged cached fix, 50ms age) fakeTelemetry.emitSample(createSample( monotonicMs: monotonic, gpsTimestamp: fixTime, gpsAgeMs: 50, )); monotonic += 50; totalEmitted++; } await pumpEventQueue(times: 30); final session = container.read(rideStateMachineProvider); expect(session.totalSamplesReceived, totalEmitted, reason: 'Every 20 Hz telemetry sample must be accepted'); expect(session.health.isForegroundServiceAlive, isTrue, reason: 'Normal 10 Hz acquisition with 20 Hz sampling must remain healthy'); }); test('F. GPS outage transitions health to recordingDegraded', () async { final container = buildContainer(); await reachHealthyActive(container); // Starting state: recordingActive expect(container.read(rideStateMachineProvider).healthStatus, RecordingHealthStatus.recordingActive); final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds; final cachedGpsTime = baseTime.add(tickWait); // Outage occurs: GPS fixes stop, but 20 Hz telemetry loop continues emitting cached fix // Emit samples advancing monotonic time past the 4000ms staleness threshold final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500; // 4500ms after last fix fakeTelemetry.emitSample(createSample( monotonicMs: outageMonotonic, gpsTimestamp: cachedGpsTime, // Stale fix gpsAgeMs: evidenceStaleMs + 500, )); await pumpEventQueue(times: 20); final degradedSession = container.read(rideStateMachineProvider); expect(degradedSession.health.isForegroundServiceAlive, isFalse); expect(degradedSession.health.canReportRecording, isFalse); expect(degradedSession.healthStatus, RecordingHealthStatus.recordingDegraded, reason: 'GPS outage past 4000ms threshold must transition health to recordingDegraded'); expect(degradedSession.state, RideState.riding, reason: 'Degradation must not cancel or abort the active ride'); }); test('G. GPS recovery restores recordingActive from recordingDegraded', () async { final container = buildContainer(); await reachHealthyActive(container); final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds; final cachedGpsTime = baseTime.add(tickWait); // 1. Induce outage final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500; fakeTelemetry.emitSample(createSample( monotonicMs: outageMonotonic, gpsTimestamp: cachedGpsTime, gpsAgeMs: evidenceStaleMs + 500, )); await pumpEventQueue(times: 20); expect(container.read(rideStateMachineProvider).healthStatus, RecordingHealthStatus.recordingDegraded); // 2. Recovery: GPS acquires fresh fix final recoveredGpsTime = cachedGpsTime.add(const Duration(seconds: 5)); final recoveredMonotonic = outageMonotonic + 50; fakeTelemetry.emitSample(createSample( monotonicMs: recoveredMonotonic, gpsTimestamp: recoveredGpsTime, // Genuinely newer fix gpsAgeMs: 0, )); await pumpEventQueue(times: 20); final recoveredSession = container.read(rideStateMachineProvider); expect(recoveredSession.health.isForegroundServiceAlive, isTrue); expect(recoveredSession.health.canReportRecording, isTrue); expect(recoveredSession.healthStatus, RecordingHealthStatus.recordingActive, reason: 'Fresh GPS fix must recover health from degraded back to recordingActive'); }); test('H. Session reset / start clears last seen GPS timestamp', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); // Ride 1 await notifier.startRide(); final futureGpsTime = DateTime(2030, 1, 1, 12, 0, 0); fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: futureGpsTime, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue); await notifier.finishRide(); await pumpEventQueue(times: 20); // Ride 2 starts with normal current time (earlier than Ride 1's future timestamp) await notifier.startRide(); final normalGpsTime = DateTime(2026, 9, 19, 12, 0, 0); fakeTelemetry.emitSample(createSample( monotonicMs: 1000, gpsTimestamp: normalGpsTime, gpsAgeMs: 0, )); await pumpEventQueue(times: 10); // Must be accepted as fresh evidence for the new ride! expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue, reason: 'New ride start must clear _lastSeenGpsTimestamp so previous ride timestamps are not inherited'); }); }); }