// RID-1 regression coverage: idempotent ride start. // // These tests reproduce the verified duplicate-ride defect deterministically. // The race window is controlled with a Completer-gated database rather than // sleeps, so no test here is timing-fragile. // // The gate/failure seam lives entirely in a test-only AppDatabase subclass // injected through the existing AppDatabase.forTesting constructor. 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; import 'package:motorcycle_dash/models/telemetry_sample.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'; /// Deterministic seam over the production database. /// /// * [startRideInvocations] proves "at most one db.startRide() may be active". /// * [gate] parks the first insert in flight so concurrent callers can be /// exercised without any timing assumption. /// * [failuresRemaining] injects a genuine start failure and then recovers. class _Rid1AppDatabase extends AppDatabase { _Rid1AppDatabase() : super.forTesting(NativeDatabase.memory()); int startRideInvocations = 0; Completer? gate; int failuresRemaining = 0; @override Future startRide({ DateTime? startTime, int? motorcycleId, int? motorcycleYear, String? motorcycleMake, String? motorcycleModel, String? motorcycleVariant, }) async { startRideInvocations++; if (failuresRemaining > 0) { failuresRemaining--; throw StateError('RID-1 simulated database start failure'); } final pendingGate = gate; if (pendingGate != null) await pendingGate.future; return super.startRide( startTime: startTime, motorcycleId: motorcycleId, motorcycleYear: motorcycleYear, motorcycleMake: motorcycleMake, motorcycleModel: motorcycleModel, motorcycleVariant: motorcycleVariant, ); } } 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 _Rid1AppDatabase db; late FakeTelemetryService fakeTelemetry; late MockAudioAlertService mockAudio; late FieldTestLogger logger; late TelemetryValidator validator; late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = _Rid1AppDatabase(); logger = FieldTestLogger(db); validator = TelemetryValidator(); fakeTelemetry = FakeTelemetryService( _NullLocationService(), _NullSensorFusionService(), _NullCompassService(), logger, validator, ); mockAudio = MockAudioAlertService(); }); tearDown(() async { // Never leave a parked gate behind or the suite would hang. final pendingGate = db.gate; if (pendingGate != null && !pendingGate.isCompleted) pendingGate.complete(); db.gate = null; fakeTelemetry.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; } TelemetrySample movingSample(int index, {double speed = 25.0}) { // 50 ms spacing mirrors the authoritative 20 Hz acquisition cadence. return TelemetrySample( timestamp: DateTime(2026, 9, 10, 12).add(Duration(milliseconds: index * 50)), latitude: 34.2570, longitude: -85.1647, speed: speed, heading: 90.0, gpsAccuracy: 3.0, ); } group('RID-1: idempotent ride start', () { test('20 Hz ready-state auto-start creates exactly one ride', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final gate = Completer(); db.gate = gate; notifier.setReady(); expect(container.read(rideStateMachineProvider).state, RideState.ready); // One second of authoritative 20 Hz telemetry. None of the resulting // startRide() triggers is awaited, exactly as the production // motion auto-start path does. for (var i = 0; i < 20; i++) { fakeTelemetry.emitSample(movingSample(i)); } // Deliver every queued stream event. The first db.startRide() is // deterministically parked on the gate, so this is the real race window. await pumpEventQueue(times: 50); expect(db.startRideInvocations, 1, reason: 'at most one db.startRide() operation may be active'); gate.complete(); await pumpEventQueue(times: 50); final rides = await db.select(db.rides).get(); expect(rides, hasLength(1), reason: 'exactly one ride row'); expect(rides.map((r) => r.cloudRideId).toSet(), hasLength(1), reason: 'exactly one cloudRideId minted'); final session = container.read(rideStateMachineProvider); expect(session.state, RideState.riding); expect(session.dbId, rides.single.id, reason: 'session.dbId must resolve to that single row'); }); test('two concurrent startRide() calls create exactly one ride', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final gate = Completer(); db.gate = gate; // Exactly what two rapid taps on the dashboard Start button do: fire // startRide() twice without awaiting. final first = notifier.startRide(); final second = notifier.startRide(); await pumpEventQueue(times: 50); expect(db.startRideInvocations, 1, reason: 'exactly one database start'); gate.complete(); await Future.wait([first, second]); final rides = await db.select(db.rides).get(); expect(rides, hasLength(1)); expect(rides.map((r) => r.cloudRideId).toSet(), hasLength(1)); final session = container.read(rideStateMachineProvider); expect(session.state, RideState.riding, reason: 'one resulting riding session'); expect(session.dbId, rides.single.id); }); test('failed database start does not report riding, clears the guard, ' 'and a later legitimate retry succeeds', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); db.failuresRemaining = 1; await expectLater(notifier.startRide(), throwsStateError); var session = container.read(rideStateMachineProvider); expect(session.state, isNot(RideState.riding), reason: 'state must not falsely become riding'); expect(session.dbId, isNull); expect(await db.select(db.rides).get(), isEmpty, reason: 'a failed start must not leave a partial row'); // Retry the genuine start. Succeeding here proves the in-flight guard was // released by the finally block rather than staying stuck. await notifier.startRide(); session = container.read(rideStateMachineProvider); expect(session.state, RideState.riding); expect(session.dbId, isNotNull); final rides = await db.select(db.rides).get(); expect(rides, hasLength(1), reason: 'no duplicate rows from the retry'); expect(db.startRideInvocations, 2); }); test('after a concurrent-start burst only one ride is left open, so ' 'recovery cannot manufacture phantom rides', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); final gate = Completer(); db.gate = gate; notifier.setReady(); for (var i = 0; i < 20; i++) { fakeTelemetry.emitSample(movingSample(i)); } await pumpEventQueue(times: 50); gate.complete(); await pumpEventQueue(times: 50); final rides = await db.select(db.rides).get(); // Mirrors the selection criterion used by _recoverInterruptedRides() // (rides whose endTime is null). Recovery behaviour itself is unchanged. final openRides = rides.where((r) => r.endTime == null).toList(); expect(openRides, hasLength(1), reason: 'exactly one open active ride after the burst'); expect(rides, hasLength(1), reason: 'no additional empty orphan ride rows'); }); test('frozen auto-start threshold is preserved at 1.5 MPH', () async { final container = buildContainer(); final notifier = container.read(rideStateMachineProvider.notifier); notifier.setReady(); // Below the frozen threshold: must not auto-start. fakeTelemetry.emitSample(movingSample(0, speed: 1.4)); await pumpEventQueue(times: 20); expect(container.read(rideStateMachineProvider).state, RideState.ready); expect(db.startRideInvocations, 0); // At the frozen threshold: must auto-start. fakeTelemetry.emitSample(movingSample(1, speed: 1.5)); await pumpEventQueue(times: 50); expect(container.read(rideStateMachineProvider).state, RideState.riding); expect(db.startRideInvocations, 1); }); }); }