427 lines
16 KiB
Dart
427 lines
16 KiB
Dart
// 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<void>? endRideGate;
|
|
int endRideFailuresRemaining = 0;
|
|
final List<int> endRideTargetIds = [];
|
|
|
|
@override
|
|
Future<void> 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<void> 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<FieldTestSummary> stopSession() async {
|
|
stopSessionInvocations++;
|
|
return super.stopSession();
|
|
}
|
|
}
|
|
|
|
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 _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<RideSession>(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<int> 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<Ride> 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<void>();
|
|
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');
|
|
});
|
|
});
|
|
}
|