import 'dart:async'; import 'package:flutter/material.dart'; 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/dashboard/widgets/dashboard_map_panel.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.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/field_test/field_test_logger.dart'; import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/map/unified_map_view.dart'; import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample, FieldTestSummary, FieldTestEvent; import 'package:drift/native.dart'; class FakeLocationService extends Fake implements LocationService { final StreamController _controller = StreamController.broadcast(); @override void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic; @override Stream getFilteredPositionStream() => _controller.stream; void emitPosition(Position position) => _controller.add(position); @override void dispose() => _controller.close(); } class FakeCompassService extends Fake implements CompassService { final StreamController _controller = StreamController.broadcast(); @override Stream get headingStream => _controller.stream; void emitHeading(double heading) => _controller.add(heading); @override void dispose() => _controller.close(); } 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; @override void updateVehicleSpeed(double speedMph) {} } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late FieldTestLogger logger; late TelemetryValidator validator; late FakeLocationService fakeLocation; late FakeCompassService fakeCompass; late TelemetryService telemetryService; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); logger = FieldTestLogger(db); validator = TelemetryValidator(); fakeLocation = FakeLocationService(); fakeCompass = FakeCompassService(); telemetryService = TelemetryService( fakeLocation, _NullSensorFusionService(), fakeCompass, logger, validator, ); }); tearDown(() async { telemetryService.dispose(); fakeLocation.dispose(); fakeCompass.dispose(); logger.dispose(); await db.close(); }); Position createPosition({ required DateTime timestamp, double speedMps = 20.0, // ~45 MPH double heading = 90.0, double lat = 34.0522, double lon = -118.2437, double accuracy = 5.0, }) { return Position( latitude: lat, longitude: lon, timestamp: timestamp, altitude: 100.0, altitudeAccuracy: 1.0, accuracy: accuracy, heading: heading, headingAccuracy: 1.0, speed: speedMps, speedAccuracy: 1.0, ); } group('Phase 1G — Heading Selection Truthfulness in TelemetryService', () { test('1. Fresh moving GPS heading remains usable and preferred over compass', () async { final now = DateTime.now(); // Emit live compass = 180° fakeCompass.emitHeading(180.0); // Emit fresh GPS fix moving at 45 MPH heading 90° fakeLocation.emitPosition(createPosition( timestamp: now, speedMps: 20.0, heading: 90.0, )); await Future.delayed(const Duration(milliseconds: 100)); final sample = await telemetryService.telemetryStream.first; // Moving GPS heading (90°) should override compass (180°) expect(sample.heading, 90.0); expect(sample.speed, greaterThan(3.0)); expect(sample.isGpsFresh, isTrue); }); test('2. Normal duplicate GPS fixes (10 Hz / 20 Hz) do not cause heading flicker', () async { final now = DateTime.now(); fakeCompass.emitHeading(220.0); // Fix arrives 100ms ago (simulating 10 Hz acquisition sampled at 20 Hz) fakeLocation.emitPosition(createPosition( timestamp: now.subtract(const Duration(milliseconds: 100)), speedMps: 15.0, heading: 75.0, )); await Future.delayed(const Duration(milliseconds: 100)); final sample = await telemetryService.telemetryStream.first; // Within 4000ms bound, duplicate fix heading remains stable expect(sample.heading, 75.0); expect(sample.isGpsFresh, isTrue); }); test('3. Stale GPS heading stops overriding live compass after 4000ms', () async { final now = DateTime.now(); // Live compass is 270° West fakeCompass.emitHeading(270.0); // GPS fix is stale (> 4000ms ago, e.g. 5000ms ago) with heading 90° East fakeLocation.emitPosition(createPosition( timestamp: now.subtract(const Duration(milliseconds: 5000)), speedMps: 20.0, heading: 90.0, )); await Future.delayed(const Duration(milliseconds: 100)); final sample = await telemetryService.telemetryStream.first; // Stale GPS must NOT override compass! Heading must fall back to 270° expect(sample.heading, 270.0); expect(sample.isGpsFresh, isFalse); }); test('4. Heading recovers automatically when fresh GPS resumes', () async { final now = DateTime.now(); fakeCompass.emitHeading(180.0); // Initially stale GPS (5000ms ago, heading 90°) fakeLocation.emitPosition(createPosition( timestamp: now.subtract(const Duration(milliseconds: 5000)), speedMps: 20.0, heading: 90.0, )); await Future.delayed(const Duration(milliseconds: 100)); final staleSample = await telemetryService.telemetryStream.first; expect(staleSample.heading, 180.0); // Compass used expect(staleSample.isGpsFresh, isFalse); // Now fresh GPS fix arrives: heading 120°, moving final freshNow = DateTime.now(); fakeLocation.emitPosition(createPosition( timestamp: freshNow, speedMps: 22.0, heading: 120.0, )); await Future.delayed(const Duration(milliseconds: 100)); final recoveredSample = await telemetryService.telemetryStream.first; // Fresh GPS automatically becomes eligible again! expect(recoveredSample.heading, 120.0); expect(recoveredSample.isGpsFresh, isTrue); }); test('5. Heading and GPS boundary condition at 3999ms, 4000ms, and 4001ms', () { final base = DateTime(2026, 9, 19, 12, 0, 0); // A. 3999 ms -> FRESH final sample3999 = TelemetrySample( timestamp: base, latitude: 34.0, longitude: -85.0, speed: 40.0, heading: 45.0, gpsTimestamp: base.subtract(const Duration(milliseconds: 3999)), gpsAgeMs: 3999, ); expect(sample3999.isGpsFresh, isTrue); // B. 4000 ms -> FRESH (inclusive canonical bound) final sample4000 = TelemetrySample( timestamp: base, latitude: 34.0, longitude: -85.0, speed: 40.0, heading: 45.0, gpsTimestamp: base.subtract(const Duration(milliseconds: 4000)), gpsAgeMs: 4000, ); expect(sample4000.isGpsFresh, isTrue); // C. 4001 ms -> STALE (exceeds canonical bound) final sample4001 = TelemetrySample( timestamp: base, latitude: 34.0, longitude: -85.0, speed: 40.0, heading: 45.0, gpsTimestamp: base.subtract(const Duration(milliseconds: 4001)), gpsAgeMs: 4001, ); expect(sample4001.isGpsFresh, isFalse); }); test('6. Fresh GPS at stationary/low speed (<= 3.0 MPH) uses compass', () async { fakeCompass.emitHeading(42.0); fakeLocation.emitPosition(createPosition( timestamp: DateTime.now(), speedMps: 0.5, // ~1.1 MPH <= 3.0 MPH heading: 90.0, )); await Future.delayed(const Duration(milliseconds: 100)); final sample = await telemetryService.telemetryStream.first; // Low speed correctly preserves compass fallback expect(sample.heading, 42.0); }); test('7. Raw telemetry values (lat, lon, gpsTimestamp, gpsAgeMs) remain preserved during stale GPS', () async { final fixTime = DateTime.now().subtract(const Duration(milliseconds: 7000)); fakeCompass.emitHeading(180.0); fakeLocation.emitPosition(createPosition( timestamp: fixTime, lat: 37.7749, lon: -122.4194, speedMps: 20.0, heading: 90.0, )); await Future.delayed(const Duration(milliseconds: 100)); final sample = await telemetryService.telemetryStream.first; // Raw values must NOT be nulled or altered expect(sample.latitude, 37.7749); expect(sample.longitude, -122.4194); expect(sample.gpsTimestamp, fixTime); expect(sample.gpsAgeMs, greaterThanOrEqualTo(7000)); // But freshness flags truthful status expect(sample.isGpsFresh, isFalse); expect(sample.isSpeedFresh, isFalse); }); }); group('Phase 1G — DashboardMapPanel & Location Presentation Truthfulness', () { late StreamController testStreamController; setUp(() { testStreamController = StreamController.broadcast(); }); tearDown(() async { await testStreamController.close(); }); Widget buildTestWidget({required Stream stream}) { return ProviderScope( overrides: [ telemetryStreamProvider.overrideWith((ref) => stream), activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), ], child: const MaterialApp( home: Scaffold( body: SizedBox( width: 400, height: 400, child: DashboardMapPanel(), ), ), ), ); } testWidgets('8. Fresh map coordinate renders normal active marker without stale badge', (tester) async { final now = DateTime.now(); final freshSample = TelemetrySample( timestamp: now, latitude: 34.0522, longitude: -118.2437, heading: 90.0, speed: 35.0, gpsTimestamp: now, gpsAgeMs: 50, ); await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream)); testStreamController.add(freshSample); await tester.pumpAndSettle(); // Stale pill badge must NOT be present expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing); expect(find.text('LAST KNOWN LOCATION'), findsNothing); // Map view is rendered expect(find.byType(UnifiedMapView), findsOneWidget); }); testWidgets('9. Stale map coordinate renders LAST KNOWN LOCATION pill badge and dimmed marker', (tester) async { final now = DateTime.now(); final staleSample = TelemetrySample( timestamp: now, latitude: 34.0522, longitude: -118.2437, heading: 90.0, speed: 35.0, gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)), gpsAgeMs: 6000, // > 4000ms -> STALE ); await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream)); testStreamController.add(staleSample); await tester.pumpAndSettle(); // Stale pill badge MUST be present expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget); expect(find.text('LAST KNOWN LOCATION'), findsOneWidget); expect(find.byIcon(Icons.location_off), findsOneWidget); }); testWidgets('10. Stale coordinate does NOT move artificially (no dead reckoning / fake drift)', (tester) async { final now = DateTime.now(); const initialLat = 34.0522; const initialLon = -118.2437; // 5 seconds of stale samples at 20 Hz await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream)); for (int i = 0; i < 10; i++) { testStreamController.add(TelemetrySample( timestamp: now.add(Duration(milliseconds: i * 50)), latitude: initialLat, longitude: initialLon, heading: 90.0, speed: 45.0, // Cached speed gpsTimestamp: now.subtract(const Duration(milliseconds: 5000)), gpsAgeMs: 5000 + (i * 50), )); } await tester.pumpAndSettle(); // Map center must remain exactly initialLat, initialLon final mapWidget = tester.widget(find.byType(UnifiedMapView)); expect(mapWidget.center.latitude, initialLat); expect(mapWidget.center.longitude, initialLon); expect(find.text('LAST KNOWN LOCATION'), findsOneWidget); }); testWidgets('11. Map presentation recovers automatically when fresh GPS resumes', (tester) async { final now = DateTime.now(); await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream)); // 1. Emit stale sample testStreamController.add(TelemetrySample( timestamp: now, latitude: 34.0522, longitude: -118.2437, heading: 90.0, speed: 35.0, gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)), gpsAgeMs: 6000, )); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget); // 2. Emit fresh sample (GPS recovered) final recoveredNow = DateTime.now(); testStreamController.add(TelemetrySample( timestamp: recoveredNow, latitude: 34.0530, longitude: -118.2430, heading: 95.0, speed: 35.0, gpsTimestamp: recoveredNow, gpsAgeMs: 10, )); await tester.pumpAndSettle(); // Stale badge disappears automatically expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing); expect(find.text('LAST KNOWN LOCATION'), findsNothing); // Coordinates updated to fresh location final mapWidget = tester.widget(find.byType(UnifiedMapView)); expect(mapWidget.center.latitude, 34.0530); expect(mapWidget.center.longitude, -118.2430); }); testWidgets('12. Truthfulness operates independently of ride state (outside an active ride)', (tester) async { // Without any ride session started or active, freshness and stale badge work directly from telemetry stream final now = DateTime.now(); final staleSample = TelemetrySample( timestamp: now, latitude: 40.7128, longitude: -74.0060, heading: 180.0, speed: 0.0, gpsTimestamp: now.subtract(const Duration(milliseconds: 5500)), gpsAgeMs: 5500, ); await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream)); testStreamController.add(staleSample); await tester.pumpAndSettle(); expect(find.text('LAST KNOWN LOCATION'), findsOneWidget); }); testWidgets('13. GPSStatusIndicator truthfully shows NO GPS when fix is stale (> 4000ms)', (tester) async { final now = DateTime.now(); Widget buildGpsWidget({required Stream stream}) { return ProviderScope( overrides: [ telemetryStreamProvider.overrideWith((ref) => stream), ], child: const MaterialApp( home: Scaffold( body: GPSStatusIndicator(), ), ), ); } // 1. Fresh fix with accuracy = 5.0 -> GPS 3D FIX await tester.pumpWidget(buildGpsWidget(stream: testStreamController.stream)); testStreamController.add(TelemetrySample( timestamp: now, latitude: 34.0, longitude: -85.0, gpsAccuracy: 5.0, gpsTimestamp: now, gpsAgeMs: 50, )); await tester.pumpAndSettle(); expect(find.text('GPS 3D FIX'), findsOneWidget); // 2. Stale fix with accuracy = 5.0 (> 4000ms) -> NO GPS testStreamController.add(TelemetrySample( timestamp: now.add(const Duration(seconds: 5)), latitude: 34.0, longitude: -85.0, gpsAccuracy: 5.0, // cached accuracy gpsTimestamp: now, gpsAgeMs: 5000, // STALE )); await tester.pumpAndSettle(); expect(find.text('NO GPS'), findsOneWidget); expect(find.byIcon(Icons.gps_off), findsOneWidget); }); }); }