511 lines
17 KiB
Dart
511 lines
17 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: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<Position> _controller =
|
|
StreamController<Position>.broadcast();
|
|
|
|
@override
|
|
void Function(Position position, bool accepted, String? rejectionReason)?
|
|
onGpsDiagnostic;
|
|
|
|
@override
|
|
Stream<Position> getFilteredPositionStream() => _controller.stream;
|
|
|
|
void emitPosition(Position position) => _controller.add(position);
|
|
|
|
@override
|
|
void dispose() => _controller.close();
|
|
}
|
|
|
|
class FakeCompassService extends Fake implements CompassService {
|
|
final StreamController<double> _controller =
|
|
StreamController<double>.broadcast();
|
|
|
|
@override
|
|
Stream<double> get headingStream => _controller.stream;
|
|
|
|
void emitHeading(double heading) => _controller.add(heading);
|
|
|
|
@override
|
|
void dispose() => _controller.close();
|
|
}
|
|
|
|
class _NullSensorFusionService extends Fake implements SensorFusionService {
|
|
@override
|
|
Stream<double> get leanAngleStream => const Stream.empty();
|
|
@override
|
|
Stream<double> get pitchAngleStream => const Stream.empty();
|
|
@override
|
|
Stream<GForceData> 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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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<TelemetrySample> testStreamController;
|
|
|
|
setUp(() {
|
|
testStreamController = StreamController<TelemetrySample>.broadcast();
|
|
});
|
|
|
|
tearDown(() async {
|
|
await testStreamController.close();
|
|
});
|
|
|
|
Widget buildTestWidget({required Stream<TelemetrySample> stream}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
telemetryStreamProvider.overrideWith((ref) => stream),
|
|
activeAlertsStreamProvider.overrideWith((ref) => Stream.value(<RoadAlert>[])),
|
|
],
|
|
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<UnifiedMapView>(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<UnifiedMapView>(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<TelemetrySample> 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);
|
|
});
|
|
});
|
|
}
|