MotorSignal-App/test/truthful_speed_presentation...

307 lines
10 KiB
Dart

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<TelemetrySample> telemetryController;
setUp(() {
telemetryController = StreamController<TelemetrySample>.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);
});
});
}