459 lines
16 KiB
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');
|
|
});
|
|
});
|
|
}
|