334 lines
12 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|