MotorSignal-App/test/rid1_ride_start_idempotency...

334 lines
12 KiB
Dart

// 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<void>? gate;
int failuresRemaining = 0;
@override
Future<int> 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<String> spokenMessages = [];
@override
Future<void> speak(String text, {bool force = false}) async {
spokenMessages.add(text);
}
}
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();
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<void>();
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<void>();
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<void>();
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);
});
});
}