// RID-2 regression coverage: idempotent ride finish. // // Converted from the deterministic RID-2 forensic reproduction. The race window // is controlled with a Completer-gated database rather than sleeps, so no test // here is timing-fragile. // // All seams are test-only subclasses injected through existing production // constructors (AppDatabase.forTesting, recordsServiceProvider, // fieldTestLoggerProvider). No production architecture was changed to make // these tests possible. import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:drift/native.dart'; import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample, FieldTestSummary, FieldTestEvent; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/models/field_test_summary.dart'; import 'package:motorcycle_dash/models/recording_health_status.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:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; /// Counts and gates db.endRide - the first instrumentable awaited dependency /// inside finishRide(). (_flushBatch() precedes it but is private and not /// overridable without a production change; it is RID-3 scope and is /// deliberately not measured here.) class _Rid2AppDatabase extends AppDatabase { _Rid2AppDatabase() : super.forTesting(NativeDatabase.memory()); int endRideInvocations = 0; Completer? endRideGate; int endRideFailuresRemaining = 0; final List endRideTargetIds = []; @override Future endRide(int rideId, RidesCompanion data) async { endRideInvocations++; endRideTargetIds.add(rideId); if (endRideFailuresRemaining > 0) { endRideFailuresRemaining--; throw StateError('RID-2 simulated endRide failure'); } final g = endRideGate; if (g != null) await g.future; return super.endRide(rideId, data); } } /// Counts personal-record evaluation passes and record award callbacks. class _CountingRecordsService extends RecordsService { _CountingRecordsService(super.prefs); int checkInvocations = 0; int newRecordCallbacks = 0; @override Future checkAndSaveRecords( PersonalRecords newStats, void Function(String, double)? onNewRecord, { double confidence = 1.0, }) async { checkInvocations++; return super.checkAndSaveRecords( newStats, onNewRecord == null ? null : (String name, double value) { newRecordCallbacks++; onNewRecord(name, value); }, confidence: confidence, ); } } /// Counts stopSession entries. The isSessionActive check lives in the caller, /// so concurrent entries are observable here. class _CountingFieldTestLogger extends FieldTestLogger { _CountingFieldTestLogger(super.db); int stopSessionInvocations = 0; @override Future stopSession() async { stopSessionInvocations++; return super.stopSession(); } } class MockAudioAlertService extends Fake implements AudioAlertService { final List spokenMessages = []; @override Future speak(String text, {bool force = false}) async { spokenMessages.add(text); } } class FakeTelemetryService extends TelemetryService { final StreamController _controller = StreamController.broadcast(); FakeTelemetryService( super.loc, super.fusion, super.compass, super.logger, super.validator, ); @override Stream 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 getFilteredPositionStream() => const Stream.empty(); } class _NullSensorFusionService extends Fake implements SensorFusionService { @override Stream get leanAngleStream => const Stream.empty(); @override Stream get pitchAngleStream => const Stream.empty(); @override Stream 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 get headingStream => const Stream.empty(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late _Rid2AppDatabase db; late _CountingFieldTestLogger logger; late _CountingRecordsService records; late FakeTelemetryService fakeTelemetry; late MockAudioAlertService mockAudio; late TelemetryValidator validator; late SharedPreferences prefs; /// Counts observed transitions into RideState.finished. This is the edge that /// drives SyncObserver, so it also proves no duplicate cloud enqueue. late int finishedTransitions; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = _Rid2AppDatabase(); logger = _CountingFieldTestLogger(db); records = _CountingRecordsService(prefs); validator = TelemetryValidator(); fakeTelemetry = FakeTelemetryService( _NullLocationService(), _NullSensorFusionService(), _NullCompassService(), logger, validator, ); mockAudio = MockAudioAlertService(); finishedTransitions = 0; }); tearDown(() async { // Never leave a parked gate behind or the suite would hang. final g = db.endRideGate; if (g != null && !g.isCompleted) g.complete(); db.endRideGate = null; 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), recordsServiceProvider.overrideWith((ref) => records), ], ); addTearDown(container.dispose); container.listen(rideStateMachineProvider, (prev, next) { if (prev?.state != RideState.finished && next.state == RideState.finished) { finishedTransitions++; } }); return container; } /// Starts a real ride and feeds it deterministic moving telemetry so the /// finish path has genuine stats, PR candidates and an active field-test /// session to finalize. Future startRideWithTelemetry(ProviderContainer container) async { final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final rideId = container.read(rideStateMachineProvider).dbId!; final base = DateTime(2026, 9, 10, 12); for (var i = 0; i < 10; i++) { fakeTelemetry.emitSample( TelemetrySample( timestamp: base.add(Duration(milliseconds: i * 50)), latitude: 34.2570 + (i * 0.0005), longitude: -85.1647, speed: 45.0 + i, heading: 90.0, gpsAccuracy: 3.0, leanAngle: 12.0, longitudinalG: 0.2, ), ); } await pumpEventQueue(times: 30); return rideId; } int sessionEndedCount() => mockAudio.spokenMessages.where((m) => m == 'Session ended').length; String? cloudRideIdOf(List rides, int rideId) => rides.singleWhere((r) => r.id == rideId).cloudRideId; group('RID-2: idempotent ride finish', () { test('CONTROL: a single finishRide() finalizes exactly once', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final rideId = await startRideWithTelemetry(container); final cloudRideIdBefore = cloudRideIdOf(await db.select(db.rides).get(), rideId); expect(container.read(rideStateMachineProvider).state, RideState.riding); await notifier.finishRide(); await pumpEventQueue(times: 30); final session = container.read(rideStateMachineProvider); final rides = await db.select(db.rides).get(); final row = rides.singleWhere((r) => r.id == rideId); final summaries = await db.select(db.fieldTestSummaries).get(); expect(db.endRideInvocations, 1); expect(records.checkInvocations, 1); expect(logger.stopSessionInvocations, 1); expect(summaries, hasLength(1)); expect(sessionEndedCount(), 1); expect(finishedTransitions, 1); expect(session.state, RideState.finished); expect(session.healthStatus, RecordingHealthStatus.recordingComplete); expect(row.endTime, isNotNull, reason: 'ride must be closed'); expect(cloudRideIdOf(rides, rideId), cloudRideIdBefore, reason: 'cloudRideId is an immutable identity contract'); expect(rides, hasLength(1)); }); test('two concurrent finishRide() calls finalize exactly once', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final rideId = await startRideWithTelemetry(container); final cloudRideIdBefore = cloudRideIdOf(await db.select(db.rides).get(), rideId); // Hold the first finish in-flight at its first instrumentable awaited // dependency, then issue a second finishRide() - exactly what two rapid // Stop taps do. Deterministic: no sleeps, no machine timing. final gate = Completer(); db.endRideGate = gate; final first = notifier.finishRide(); final second = notifier.finishRide(); await pumpEventQueue(times: 40); expect(db.endRideInvocations, 1, reason: 'second caller must not enter while first is in flight'); gate.complete(); await Future.wait([first, second]); await pumpEventQueue(times: 40); final session = container.read(rideStateMachineProvider); final rides = await db.select(db.rides).get(); final row = rides.singleWhere((r) => r.id == rideId); final summaries = await db.select(db.fieldTestSummaries).get(); expect(db.endRideInvocations, 1, reason: 'db.endRide must run once'); expect(db.endRideTargetIds, [rideId]); expect(records.checkInvocations, 1, reason: 'one records pass'); expect(logger.stopSessionInvocations, 1, reason: 'one logger stop'); expect(summaries, hasLength(1), reason: 'one fieldTestSummary per ride'); expect(summaries.single.rideId, rideId); expect(sessionEndedCount(), 1, reason: 'one "Session ended"'); expect(finishedTransitions, 1, reason: 'one finished transition -> one SyncObserver notify'); expect(rides, hasLength(1), reason: 'no duplicate ride row'); expect(row.endTime, isNotNull); expect(cloudRideIdOf(rides, rideId), cloudRideIdBefore, reason: 'no new cloudRideId minted'); expect(session.state, RideState.finished); expect(session.dbId, rideId); }); test('a second finishRide() after successful completion is a no-op', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final rideId = await startRideWithTelemetry(container); await notifier.finishRide(); await pumpEventQueue(times: 30); expect(await db.select(db.fieldTestSummaries).get(), hasLength(1)); // Sequential second finish - a late second Stop tap arriving after the // first has already completed. await notifier.finishRide(); await pumpEventQueue(times: 30); expect(db.endRideInvocations, 1, reason: 'no second db.endRide'); expect(records.checkInvocations, 1, reason: 'no second records pass'); expect(logger.stopSessionInvocations, 1, reason: 'no second logger stop'); expect(await db.select(db.fieldTestSummaries).get(), hasLength(1), reason: 'no second fieldTestSummary'); expect(sessionEndedCount(), 1, reason: 'no second "Session ended"'); expect(finishedTransitions, 1, reason: 'no second finished transition'); expect(container.read(rideStateMachineProvider).state, RideState.finished); expect(await db.select(db.rides).get(), hasLength(1)); expect(db.endRideTargetIds, [rideId]); }); test('failed db.endRide does not falsely finish, releases the guard, ' 'and a retry finalizes exactly once', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final rideId = await startRideWithTelemetry(container); final cloudRideIdBefore = cloudRideIdOf(await db.select(db.rides).get(), rideId); db.endRideFailuresRemaining = 1; // finishRide retains meaningful Future error semantics for callers that // deliberately await it. Containment happens at the UI boundary instead. await expectLater(notifier.finishRide(), throwsStateError); await pumpEventQueue(times: 30); final afterFailure = container.read(rideStateMachineProvider); final rowAfterFailure = (await db.select(db.rides).get()) .singleWhere((r) => r.id == rideId); expect(afterFailure.state, isNot(RideState.finished), reason: 'must not falsely report finished after persistence failure'); expect(rowAfterFailure.endTime, isNull, reason: 'must not fabricate an endTime for an unpersisted finish'); expect(finishedTransitions, 0, reason: 'no finished transition -> no false cloud sync completion'); expect(records.checkInvocations, 0); expect(logger.stopSessionInvocations, 0); expect(logger.isSessionActive, isTrue, reason: 'field-test session must stay recoverable for the retry'); expect(await db.select(db.fieldTestSummaries).get(), isEmpty); expect(sessionEndedCount(), 0); // The guard must have been released by the finally block, so a legitimate // explicit retry still succeeds. await notifier.finishRide(); await pumpEventQueue(times: 30); final afterRetry = container.read(rideStateMachineProvider); final rides = await db.select(db.rides).get(); final rowAfterRetry = rides.singleWhere((r) => r.id == rideId); final summaries = await db.select(db.fieldTestSummaries).get(); expect(db.endRideInvocations, 2, reason: '1 injected failure + 1 successful retry is expected'); expect(afterRetry.state, RideState.finished); expect(afterRetry.healthStatus, RecordingHealthStatus.recordingComplete); expect(rowAfterRetry.endTime, isNotNull, reason: 'exactly one successful persisted finalization'); expect(records.checkInvocations, 1, reason: 'exactly one records pass'); expect(logger.stopSessionInvocations, 1, reason: 'exactly one logger stop'); expect(summaries, hasLength(1), reason: 'exactly one fieldTestSummary'); expect(sessionEndedCount(), 1, reason: 'exactly one "Session ended"'); expect(finishedTransitions, 1, reason: 'exactly one finished transition'); expect(rides, hasLength(1), reason: 'no duplicate ride row'); expect(cloudRideIdOf(rides, rideId), cloudRideIdBefore, reason: 'no new cloudRideId minted'); }); }); }