MotorSignal-App/test/truthful_heading_location_t...

509 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;
}
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);
});
});
}