MotorSignal-App/test/stale_gps_regression_test.dart

459 lines
16 KiB
Dart

import 'dart:async';
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/models/recording_health_status.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/records/records_service.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/audio_alert_service.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart'
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
import 'package:drift/native.dart';
class MockAudioAlertService extends Fake implements AudioAlertService {
@override
Future<void> speak(String text, {bool force = false}) async {}
}
class FakeTelemetryService extends TelemetryService {
final StreamController<TelemetrySample> _controller =
StreamController<TelemetrySample>.broadcast();
FakeTelemetryService(
super.loc,
super.fusion,
super.compass,
super.logger,
super.validator,
);
@override
Stream<TelemetrySample> get telemetryStream => _controller.stream;
void emitSample(TelemetrySample sample) => _controller.add(sample);
@override
void dispose() => _controller.close();
}
class _NullLocationService extends Fake implements LocationService {
@override
void Function(Position position, bool accepted, String? rejectionReason)?
onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => const Stream.empty();
}
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;
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const tickWait = Duration(milliseconds: 3300);
const evidenceStaleMs = RecordingHealthContract.evidenceStaleAfterMs; // 4000ms
late AppDatabase db;
late FieldTestLogger logger;
late FakeTelemetryService fakeTelemetry;
late MockAudioAlertService mockAudio;
late TelemetryValidator validator;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeTelemetry = FakeTelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
validator,
);
mockAudio = MockAudioAlertService();
});
tearDown(() async {
fakeTelemetry.dispose();
logger.dispose();
await db.close();
});
ProviderContainer buildContainer() {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
return container;
}
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
TelemetrySample createSample({
required int monotonicMs,
DateTime? gpsTimestamp,
int? gpsAgeMs,
DateTime? imuTimestamp,
double speed = 35.0,
}) {
return TelemetrySample(
timestamp: baseTime.add(Duration(milliseconds: monotonicMs)),
monotonicTimestampMs: monotonicMs,
latitude: 34.0,
longitude: -85.0,
speed: speed,
gpsTimestamp: gpsTimestamp,
gpsAgeMs: gpsAgeMs,
imuTimestamp: imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)),
leanAngle: 0.0,
pitchAngle: 0.0,
longitudinalG: 0.0,
lateralG: 0.0,
);
}
Future<void> reachHealthyActive(ProviderContainer container) async {
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Initial evidence at monotonic 1000ms
final t1 = baseTime;
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: t1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
// Wait for periodic batch write timer (3.0s) to commit to DB
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 20);
// Re-establish sensor freshness
final t2 = baseTime.add(tickWait);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000 + tickWait.inMilliseconds,
gpsTimestamp: t2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingActive);
}
group('Phase 1D: Stale-GPS Freshness Semantics', () {
test('A. Duplicate GPS timestamp does NOT refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
// Sample 1: first GPS fix at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Subsequent samples at M2 = 2000, M3 = 3000, M4 = 5500 all carry same gpsT1
fakeTelemetry.emitSample(createSample(
monotonicMs: 2000,
gpsTimestamp: gpsT1,
gpsAgeMs: 1000,
));
await pumpEventQueue(times: 10);
// Still within 4000ms evidence window from M1=1000 (age is 1000ms)
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// At M4 = 5500, elapsed monotonic time since M1 (1000) is 4500ms > evidenceStaleAfterMs (4000)
// Because gpsT1 was repeated, GPS evidence was NOT refreshed.
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: gpsT1,
gpsAgeMs: 4500,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Duplicate GPS timestamp must not refresh GPS evidence; evidence must be stale');
});
test('B. Genuine newer GPS timestamp refreshes GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
final gpsT2 = baseTime.add(const Duration(seconds: 4));
// Sample 1: GPS T1 at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
// Sample 2: Genuine newer GPS T2 at M2 = 5000 (4000ms later)
fakeTelemetry.emitSample(createSample(
monotonicMs: 5000,
gpsTimestamp: gpsT2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Sample 3 at M3 = 7000 (age relative to M2 is 2000ms <= 4000ms)
fakeTelemetry.emitSample(createSample(
monotonicMs: 7000,
gpsTimestamp: gpsT2,
gpsAgeMs: 2000,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
reason: 'T2 refreshed GPS evidence at M2=5000, so at M3=7000 it is only 2000ms old and still fresh');
});
test('C. Older/out-of-order GPS timestamp does not refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
final gpsT2 = baseTime.add(const Duration(seconds: 2));
// Accept T2 at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Out-of-order older sample carrying T1 (< T2) arrives at M2 = 5500
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: gpsT1,
gpsAgeMs: 4500,
));
await pumpEventQueue(times: 10);
// Monotonic age from M1(1000) to M2(5500) is 4500ms > 4000ms.
// Older T1 must not refresh GPS evidence.
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Older out-of-order GPS timestamp must not refresh GPS evidence');
});
test('D. Null GPS timestamp does not refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
// Sample with GPS at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Sample without GPS at M2 = 5500
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: null,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Null GPS timestamp must not refresh GPS evidence');
});
test('E. Normal 10 Hz / 20 Hz operation maintains healthy GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Simulate 10 GPS fixes (100ms apart), each sampled twice at 20 Hz (50ms interval)
int monotonic = 1000;
int totalEmitted = 0;
for (int fix = 0; fix < 10; fix++) {
final fixTime = baseTime.add(Duration(milliseconds: fix * 100));
// First sample for this fix (0ms age)
fakeTelemetry.emitSample(createSample(
monotonicMs: monotonic,
gpsTimestamp: fixTime,
gpsAgeMs: 0,
));
monotonic += 50;
totalEmitted++;
// Second sample for this fix (repackaged cached fix, 50ms age)
fakeTelemetry.emitSample(createSample(
monotonicMs: monotonic,
gpsTimestamp: fixTime,
gpsAgeMs: 50,
));
monotonic += 50;
totalEmitted++;
}
await pumpEventQueue(times: 30);
final session = container.read(rideStateMachineProvider);
expect(session.totalSamplesReceived, totalEmitted,
reason: 'Every 20 Hz telemetry sample must be accepted');
expect(session.health.isForegroundServiceAlive, isTrue,
reason: 'Normal 10 Hz acquisition with 20 Hz sampling must remain healthy');
});
test('F. GPS outage transitions health to recordingDegraded', () async {
final container = buildContainer();
await reachHealthyActive(container);
// Starting state: recordingActive
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingActive);
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
final cachedGpsTime = baseTime.add(tickWait);
// Outage occurs: GPS fixes stop, but 20 Hz telemetry loop continues emitting cached fix
// Emit samples advancing monotonic time past the 4000ms staleness threshold
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500; // 4500ms after last fix
fakeTelemetry.emitSample(createSample(
monotonicMs: outageMonotonic,
gpsTimestamp: cachedGpsTime, // Stale fix
gpsAgeMs: evidenceStaleMs + 500,
));
await pumpEventQueue(times: 20);
final degradedSession = container.read(rideStateMachineProvider);
expect(degradedSession.health.isForegroundServiceAlive, isFalse);
expect(degradedSession.health.canReportRecording, isFalse);
expect(degradedSession.healthStatus, RecordingHealthStatus.recordingDegraded,
reason: 'GPS outage past 4000ms threshold must transition health to recordingDegraded');
expect(degradedSession.state, RideState.riding,
reason: 'Degradation must not cancel or abort the active ride');
});
test('G. GPS recovery restores recordingActive from recordingDegraded', () async {
final container = buildContainer();
await reachHealthyActive(container);
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
final cachedGpsTime = baseTime.add(tickWait);
// 1. Induce outage
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500;
fakeTelemetry.emitSample(createSample(
monotonicMs: outageMonotonic,
gpsTimestamp: cachedGpsTime,
gpsAgeMs: evidenceStaleMs + 500,
));
await pumpEventQueue(times: 20);
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingDegraded);
// 2. Recovery: GPS acquires fresh fix
final recoveredGpsTime = cachedGpsTime.add(const Duration(seconds: 5));
final recoveredMonotonic = outageMonotonic + 50;
fakeTelemetry.emitSample(createSample(
monotonicMs: recoveredMonotonic,
gpsTimestamp: recoveredGpsTime, // Genuinely newer fix
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
final recoveredSession = container.read(rideStateMachineProvider);
expect(recoveredSession.health.isForegroundServiceAlive, isTrue);
expect(recoveredSession.health.canReportRecording, isTrue);
expect(recoveredSession.healthStatus, RecordingHealthStatus.recordingActive,
reason: 'Fresh GPS fix must recover health from degraded back to recordingActive');
});
test('H. Session reset / start clears last seen GPS timestamp', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
// Ride 1
await notifier.startRide();
final futureGpsTime = DateTime(2030, 1, 1, 12, 0, 0);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: futureGpsTime,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
await notifier.finishRide();
await pumpEventQueue(times: 20);
// Ride 2 starts with normal current time (earlier than Ride 1's future timestamp)
await notifier.startRide();
final normalGpsTime = DateTime(2026, 9, 19, 12, 0, 0);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: normalGpsTime,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
// Must be accepted as fresh evidence for the new ride!
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
reason: 'New ride start must clear _lastSeenGpsTimestamp so previous ride timestamps are not inherited');
});
});
}