307 lines
10 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|