From 6161233b2db79eb05c0979d0744a61669ab99bb7 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 10 Sep 2026 04:33:41 -0400 Subject: [PATCH] fix: make ride finish idempotent and contain stop errors --- .../widgets/evolution_portrait_dashboard.dart | 13 +- .../dashboard/widgets/ride_action_button.dart | 14 +- lib/features/rides/ride_state_machine.dart | 36 ++ test/rid2_ride_finish_idempotency_test.dart | 426 ++++++++++++++++++ ...d2_stop_button_error_containment_test.dart | 147 ++++++ 5 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 test/rid2_ride_finish_idempotency_test.dart create mode 100644 test/rid2_stop_button_error_containment_test.dart diff --git a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart index 29ab743..f2797ae 100644 --- a/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart +++ b/lib/features/dashboard/widgets/evolution_portrait_dashboard.dart @@ -345,7 +345,18 @@ class EvolutionPortraitDashboard extends ConsumerWidget { child: ElevatedButton( onPressed: () { if (session.state == RideState.riding) { - ref.read(rideStateMachineProvider.notifier).finishRide(); + // RID-2: contain the fire-and-forget Stop action at the UI + // boundary. finishRide() keeps its Future error semantics + // for awaiting callers; only the exception class is logged, + // never its payload. Bounded to one line per tap. + ref + .read(rideStateMachineProvider.notifier) + .finishRide() + .catchError((Object error) { + debugPrint( + '[RideStop] finish failed class=${error.runtimeType}', + ); + }); } else { ref.read(rideStateMachineProvider.notifier).startRide(); } diff --git a/lib/features/dashboard/widgets/ride_action_button.dart b/lib/features/dashboard/widgets/ride_action_button.dart index 450db7c..8328516 100644 --- a/lib/features/dashboard/widgets/ride_action_button.dart +++ b/lib/features/dashboard/widgets/ride_action_button.dart @@ -55,7 +55,19 @@ class RideActionButton extends ConsumerWidget { onPressed: () { final notifier = ref.read(rideStateMachineProvider.notifier); if (isRiding) { - notifier.finishRide(); + // RID-2: the Stop action is fire-and-forget. finishRide() retains + // its Future error semantics for callers that deliberately await + // it, so the failure is contained here at the UI boundary using the + // project's existing catchError convention + // (create_group_ride_screen.dart) and its runtimeType-only + // diagnostic convention (account_screen.dart, sync_observer.dart). + // Only the exception class is logged - never its payload - so + // tokens and ride content cannot leak, and output is bounded to one + // line per tap. No success is fabricated, no error silently + // swallowed. + notifier.finishRide().catchError((Object error) { + debugPrint('[RideStop] finish failed class=${error.runtimeType}'); + }); } else { notifier.startRide(); } diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index e9c7592..cf2e610 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -129,6 +129,15 @@ class RideStateMachine extends StateNotifier { // would re-open the duplicate-start race. bool _isStarting = false; + // RID-2: re-entrancy guard for ride finish. finishRide() previously left + // state.state == riding across every await (only healthStatus changed), so + // two rapid Stop taps both entered finalization and duplicated db.endRide, + // personal-record evaluation, logger.stopSession, the field-test summary row + // and the "Session ended" announcement. Raised synchronously before the + // first await; lifetime is owned exclusively by the in-flight finish + // operation (try/finally) and it is intentionally NOT cleared anywhere else. + bool _isFinishing = false; + RideStateMachine(this.db, this.telemetryService, this.recordsService, this.audioAlertService, this.ref) : super(RideSession()) { _telemetrySub = telemetryService.telemetryStream.listen(_onTelemetryUpdate); @@ -337,7 +346,34 @@ class RideStateMachine extends StateNotifier { } catch (_) {} } + /// RID-2: idempotent ride finish. + /// + /// The guard is raised synchronously, before the first `await`, so concurrent + /// Stop taps collapse into a single finalization instead of each persisting + /// their own end-of-ride write and forensic summary. A finish after a + /// successful completion is a no-op. + /// + /// The guard is released in `finally`, so a persistence failure never leaves + /// the machine permanently unable to finalize and a later explicit retry + /// still succeeds. Failure semantics are deliberately unchanged: a failed + /// `db.endRide` still propagates to callers that await this method, still + /// leaves the session in its existing recoverable riding/finalizing state, + /// and still does not fabricate an `endTime`, emit a finished transition, or + /// enqueue an unpersisted ride for cloud sync. Future finishRide() async { + if (_isFinishing) return; + if (state.state == RideState.finished) return; + _isFinishing = true; + try { + await _performFinishRide(); + } finally { + _isFinishing = false; + } + } + + /// Performs the actual ride finalization. Guarded exclusively by + /// [finishRide] and must never be invoked directly. + Future _performFinishRide() async { if (state.dbId != null) { state = state.copyWith(healthStatus: RecordingHealthStatus.recordingFinalizing); diff --git a/test/rid2_ride_finish_idempotency_test.dart b/test/rid2_ride_finish_idempotency_test.dart new file mode 100644 index 0000000..9fe5ba6 --- /dev/null +++ b/test/rid2_ride_finish_idempotency_test.dart @@ -0,0 +1,426 @@ +// 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'); + }); + }); +} diff --git a/test/rid2_stop_button_error_containment_test.dart b/test/rid2_stop_button_error_containment_test.dart new file mode 100644 index 0000000..4504f6c --- /dev/null +++ b/test/rid2_stop_button_error_containment_test.dart @@ -0,0 +1,147 @@ +// RID-2 regression coverage: UI containment of the fire-and-forget Stop action. +// +// Both production Stop call sites (ride_action_button.dart and +// evolution_portrait_dashboard.dart) invoke finishRide() without awaiting it. +// finishRide() deliberately retains its Future error semantics for callers that +// do await it, so containment belongs at the UI boundary via the project's +// existing catchError + runtimeType-only diagnostic convention. +// +// flutter_test fails a test on any unhandled async error, so the rejection test +// below is a genuine regression: it fails if the catchError containment is +// removed from the button. +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/features/dashboard/widgets/ride_action_button.dart'; +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/recording_health_status.dart'; + +class _FailingFinishStateMachine extends StateNotifier + implements RideStateMachine { + _FailingFinishStateMachine(super.initial); + + /// Payload that must never reach diagnostics. + static const String secretPayload = 'RID-2-SENSITIVE-TOKEN-do-not-log'; + + int finishCalls = 0; + + @override + Future finishRide() async { + finishCalls++; + throw StateError(secretPayload); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _SucceedingFinishStateMachine extends StateNotifier + implements RideStateMachine { + _SucceedingFinishStateMachine(super.initial); + + int finishCalls = 0; + + @override + Future finishRide() async { + finishCalls++; + state = state.copyWith( + state: RideState.finished, + healthStatus: RecordingHealthStatus.recordingComplete, + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + RideSession ridingSession() => RideSession( + dbId: 1, + state: RideState.riding, + healthStatus: RecordingHealthStatus.recordingActive, + ); + + Widget wrap(Override override) => ProviderScope( + overrides: [override], + child: const MaterialApp(home: Scaffold(body: RideActionButton())), + ); + + // debugPrint must be restored before the test body returns: flutter_test + // asserts that foundation debug variables are unset at the end of the body, + // which happens before tearDown callbacks run. + Future captureDebugPrint( + List sink, + Future Function() body, + ) async { + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) sink.add(message); + }; + try { + await body(); + } finally { + debugPrint = original; + } + } + + testWidgets('a rejected finishRide Future is contained at the Stop button', + (tester) async { + final logLines = []; + final fake = _FailingFinishStateMachine(ridingSession()); + + await captureDebugPrint(logLines, () async { + await tester.pumpWidget( + wrap(rideStateMachineProvider.overrideWith((ref) => fake)), + ); + await tester.pumpAndSettle(); + expect(find.text('Stop Session'), findsOneWidget); + + await tester.tap(find.text('Stop Session')); + await tester.pumpAndSettle(); + + // Had the rejected Future been left unhandled, flutter_test would have + // surfaced it as an unhandled async error and failed this test. + expect(fake.finishCalls, 1); + + final rideStopLines = + logLines.where((l) => l.contains('[RideStop]')).toList(); + expect(rideStopLines, hasLength(1), + reason: 'bounded diagnostic: one line per Stop tap'); + expect(rideStopLines.single, contains('StateError'), + reason: 'the exception class must be reported, not swallowed'); + expect(logLines.join('\n'), isNot(contains( + _FailingFinishStateMachine.secretPayload)), + reason: 'exception payload/secrets must never be printed'); + + // No false successful completion is created by the containment. + expect(fake.state.state, RideState.riding); + expect(find.text('Stop Session'), findsOneWidget); + expect(find.text('Start Session'), findsNothing); + }); + }); + + testWidgets('containment does not break the successful Stop path', + (tester) async { + final logLines = []; + final fake = _SucceedingFinishStateMachine(ridingSession()); + + await captureDebugPrint(logLines, () async { + await tester.pumpWidget( + wrap(rideStateMachineProvider.overrideWith((ref) => fake)), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Stop Session')); + await tester.pumpAndSettle(); + + expect(fake.finishCalls, 1); + expect(logLines.where((l) => l.contains('[RideStop]')), isEmpty, + reason: 'no diagnostic on a successful finish'); + expect(fake.state.state, RideState.finished); + expect(find.text('Start Session'), findsOneWidget); + expect(find.text('Stop Session'), findsNothing); + }); + }); +}