import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); final baseTime = DateTime(2026, 9, 19, 12, 0, 0); group('Phase 1E: TelemetrySample Speed Freshness Semantic Model', () { test('A. Normal moving GPS provides fresh speed', () { final sample = TelemetrySample( timestamp: baseTime, speed: 50.0, gpsTimestamp: baseTime, gpsAgeMs: 50, ); expect(sample.isSpeedFresh, isTrue); expect(sample.freshSpeed, 50.0); }); test('B. Legitimate 10 Hz / 20 Hz duplication remains fresh within 4000ms bound', () { final sample1 = TelemetrySample( timestamp: baseTime, speed: 50.0, gpsTimestamp: baseTime, gpsAgeMs: 0, ); final sample2 = TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 50)), speed: 50.0, gpsTimestamp: baseTime, // Cached fix reused 50ms later gpsAgeMs: 50, ); expect(sample1.isSpeedFresh, isTrue); expect(sample1.freshSpeed, 50.0); expect(sample2.isSpeedFresh, isTrue); expect(sample2.freshSpeed, 50.0); }); test('C & D. GNSS outage: stale speed becomes untrustworthy and does not fabricate zero', () { final staleSample = TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 5)), speed: 50.0, // Cached speed from 5s ago gpsTimestamp: baseTime, gpsAgeMs: 5000, // Exceeds 4000ms threshold ); expect(staleSample.isSpeedFresh, isFalse, reason: 'GPS fix age > 4000ms must be marked as stale'); expect(staleSample.freshSpeed, isNull, reason: 'Stale speed must be null, never presenting old speed as current'); expect(staleSample.speed, 50.0, reason: 'Underlying raw speed is preserved for forensic analysis'); expect(staleSample.freshSpeed, isNot(0.0), reason: 'Must NOT fabricate 0.0 MPH for a stale condition'); }); test('E. Genuine stopped motorcycle is distinguishable from stale/unavailable GPS', () { final stoppedSample = TelemetrySample( timestamp: baseTime, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 50, ); expect(stoppedSample.isSpeedFresh, isTrue); expect(stoppedSample.freshSpeed, 0.0, reason: 'Fresh 0.0 MPH must be recognized as valid zero-speed measurement'); }); test('F. Recovery from outage produces fresh speed again', () { final recoveredSample = TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 10)), speed: 47.0, gpsTimestamp: baseTime.add(const Duration(seconds: 10)), gpsAgeMs: 0, ); expect(recoveredSample.isSpeedFresh, isTrue); expect(recoveredSample.freshSpeed, 47.0); }); test('Null speed fix is not fresh', () { final nullSpeedSample = TelemetrySample( timestamp: baseTime, speed: null, ); expect(nullSpeedSample.isSpeedFresh, isFalse); expect(nullSpeedSample.freshSpeed, isNull); }); }); group('Phase 1E: SpeedReadout Widget Presentation Tests', () { late StreamController telemetryController; setUp(() { telemetryController = StreamController.broadcast(); }); tearDown(() { telemetryController.close(); }); Widget buildTestHarness({SpeedReadoutSize size = SpeedReadoutSize.regular}) { return ProviderScope( overrides: [ telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), ], child: MaterialApp( home: Scaffold( body: Center( child: SpeedReadout(size: size), ), ), ), ); } testWidgets('Initial state before telemetry emits shows 0', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(); expect(find.text('0'), findsOneWidget); expect(find.text('MPH'), findsOneWidget); }); testWidgets('A. Normal moving GPS renders fresh numerical speed', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(); // Emit 48 MPH telemetryController.add(TelemetrySample( timestamp: baseTime, speed: 48.2, gpsTimestamp: baseTime, gpsAgeMs: 50, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('48'), findsOneWidget); // Emit 50 MPH telemetryController.add(TelemetrySample( timestamp: baseTime.add(const Duration(milliseconds: 100)), speed: 50.1, gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)), gpsAgeMs: 50, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('50'), findsOneWidget); }); testWidgets('B. Legitimate 10 Hz / 20 Hz duplication renders smoothly without dropping to --', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(const Duration(milliseconds: 50)); final t1 = baseTime; // Tick 1: New fix T1 (50 MPH) telemetryController.add(TelemetrySample( timestamp: t1, speed: 50.0, gpsTimestamp: t1, gpsAgeMs: 0, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('50'), findsOneWidget); // Tick 2: 50ms later, repackaged T1 (50 MPH, age 50ms) telemetryController.add(TelemetrySample( timestamp: t1.add(const Duration(milliseconds: 50)), speed: 50.0, gpsTimestamp: t1, gpsAgeMs: 50, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('50'), findsOneWidget, reason: 'Intermediate 20 Hz duplicate must continue displaying 50 smoothly'); expect(find.text('--'), findsNothing); }); testWidgets('C & D. GNSS outage displays -- and does NOT fabricate 0 or keep 50 MPH', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(const Duration(milliseconds: 50)); // Initial fresh 50 MPH telemetryController.add(TelemetrySample( timestamp: baseTime, speed: 50.0, gpsTimestamp: baseTime, gpsAgeMs: 0, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('50'), findsOneWidget); // GPS outage: 5 seconds elapse without new GPS fix telemetryController.add(TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 5)), speed: 50.0, // Stale cached speed gpsTimestamp: baseTime, gpsAgeMs: 5000, // Stale age > 4000ms )); await tester.pump(const Duration(milliseconds: 50)); // UI must no longer display 50 expect(find.text('50'), findsNothing, reason: 'Stale 50 MPH speed must not remain displayed during an outage'); // UI must display '--' expect(find.text('--'), findsOneWidget, reason: 'Stale speed must be rendered as "--" placeholder'); // UI must NOT fabricate '0' expect(find.text('0'), findsNothing, reason: 'Stale speed must NOT be fabricated as 0 MPH'); }); testWidgets('E. Genuine stopped motorcycle renders 0, distinguishable from --', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(const Duration(milliseconds: 50)); // Fresh 0.0 MPH fix telemetryController.add(TelemetrySample( timestamp: baseTime, speed: 0.0, gpsTimestamp: baseTime, gpsAgeMs: 50, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('0'), findsOneWidget, reason: 'Fresh stopped reading must display "0"'); expect(find.text('--'), findsNothing); }); testWidgets('F. Recovery from outage restores speed display automatically', (tester) async { await tester.pumpWidget(buildTestHarness()); await tester.pump(const Duration(milliseconds: 50)); // 1. Enter outage telemetryController.add(TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 5)), speed: 50.0, gpsTimestamp: baseTime, gpsAgeMs: 5000, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('--'), findsOneWidget); // 2. Recovery fix arrives with 47 MPH telemetryController.add(TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 6)), speed: 47.0, gpsTimestamp: baseTime.add(const Duration(seconds: 6)), gpsAgeMs: 0, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('47'), findsOneWidget, reason: 'Recovery must automatically display fresh speed without user intervention'); expect(find.text('--'), findsNothing); }); testWidgets('G. Ride-independent behavior: works outside an active ride', (tester) async { // Build test harness with rideStateMachineProvider in idle state (not riding) final harness = ProviderScope( overrides: [ telemetryStreamProvider.overrideWith((ref) => telemetryController.stream), ], child: MaterialApp( home: Scaffold( body: SpeedReadout(), ), ), ); await tester.pumpWidget(harness); await tester.pump(const Duration(milliseconds: 50)); // Outside a ride, fresh speed renders normally telemetryController.add(TelemetrySample( timestamp: baseTime, speed: 35.0, gpsTimestamp: baseTime, gpsAgeMs: 50, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('35'), findsOneWidget); // Outside a ride, stale speed becomes '--' telemetryController.add(TelemetrySample( timestamp: baseTime.add(const Duration(seconds: 5)), speed: 35.0, gpsTimestamp: baseTime, gpsAgeMs: 5000, )); await tester.pump(const Duration(milliseconds: 50)); expect(find.text('--'), findsOneWidget); }); }); }