From ec759ea3c5a3c0c97ee77bd097c3634681d0aae7 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 10 Sep 2026 17:13:27 -0400 Subject: [PATCH] fix: make recording health evidence-based and notification truthful --- lib/features/rides/ride_state_machine.dart | 235 ++++++- lib/models/recording_health_status.dart | 103 +++ lib/services/location_service.dart | 22 +- test/forensic_lifecycle_audit_test.dart | 10 +- ...rid3c1_truthful_recording_health_test.dart | 632 ++++++++++++++++++ 5 files changed, 974 insertions(+), 28 deletions(-) create mode 100644 test/rid3c1_truthful_recording_health_test.dart diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index 2f568d9..2df4ae7 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -20,6 +20,15 @@ class RideSession { final int? dbId; final RideState state; final RecordingHealthStatus healthStatus; + + /// RID-3C-1: the ONE authoritative in-memory recording-health evidence model. + /// + /// Ephemeral runtime state only - never persisted. `RideSession` has no JSON or + /// Drift serialization, so exposing the contract here needs no schema change, + /// migration, persistence change or cloud schema change, and creates no second + /// health provider, StateNotifier, singleton or duplicate model. + final RecordingHealthContract health; + final double tripDistanceMiles; final double topSpeedMph; final double maxLeanLeft; @@ -41,6 +50,7 @@ class RideSession { this.dbId, this.state = RideState.idle, this.healthStatus = RecordingHealthStatus.recordingArmed, + this.health = const RecordingHealthContract(), this.tripDistanceMiles = 0.0, this.topSpeedMph = 0.0, this.maxLeanLeft = 0.0, @@ -61,6 +71,7 @@ class RideSession { int? dbId, RideState? state, RecordingHealthStatus? healthStatus, + RecordingHealthContract? health, double? tripDistanceMiles, double? topSpeedMph, double? maxLeanLeft, @@ -80,6 +91,7 @@ class RideSession { dbId: dbId ?? this.dbId, state: state ?? this.state, healthStatus: healthStatus ?? this.healthStatus, + health: health ?? this.health, tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles, topSpeedMph: topSpeedMph ?? this.topSpeedMph, maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft, @@ -182,26 +194,165 @@ class RideStateMachine extends StateNotifier { _recoverInterruptedRides(); } + // --------------------------------------------------------------------------- + // RID-3C-1: truthful, evidence-based recording health. + // + // Evidence markers are MONOTONIC values taken from TelemetryService's own + // monotonic domain (TelemetrySample.monotonicTimestampMs, sourced from a + // Stopwatch), so a user or system wall-clock jump can never make stale evidence + // look fresh. Wall-clock DateTimes are retained only as contract metadata. No + // new timing subsystem is introduced. + // + // Presence - never magnitude - proves a sensor sample arrived, so a genuine + // zero-valued lean, pitch or g-force still counts as real evidence. + // --------------------------------------------------------------------------- + int? _latestObservedMonotonicMs; + int? _lastGpsEvidenceMonotonicMs; + int? _lastImuEvidenceMonotonicMs; + int? _lastDbCommitMonotonicMs; + bool _dbPersistenceFailed = false; + + /// Telemetry evidence time, advanced ONLY for samples accepted for the active + /// ride. Deliberately separate from `_lastTelemetryReceivedTime`, which the + /// existing watchdog updates before RID-3B-1's acceptance cutoff and which must + /// not be moved: a post-cutoff sample may not refresh health evidence. + DateTime? _lastAcceptedTelemetryTime; + + void _resetHealthEvidence() { + _latestObservedMonotonicMs = null; + _lastGpsEvidenceMonotonicMs = null; + _lastImuEvidenceMonotonicMs = null; + _lastDbCommitMonotonicMs = null; + _dbPersistenceFailed = false; + _lastAcceptedTelemetryTime = null; + } + + /// Monotonic age in ms of a marker, or null when it cannot be established. + /// Null ALWAYS means "not fresh" - this model fails closed. + int? _monotonicAgeMs(int? markerMonotonicMs) { + final reference = _latestObservedMonotonicMs; + if (reference == null || markerMonotonicMs == null) return null; + final age = reference - markerMonotonicMs; + // A negative age is not physically meaningful here; fail closed rather than + // reporting it as fresh. + return age < 0 ? null : age; + } + + bool _isFresh(int? ageMs, int boundMs) => ageMs != null && ageMs <= boundMs; + + /// Records what an ACCEPTED telemetry sample actually proves. Called only after + /// RID-3B-1's acceptance cutoff gate, so post-cutoff samples are excluded. + void _recordTelemetryEvidence(TelemetrySample sample) { + final monotonicMs = sample.monotonicTimestampMs; + if (monotonicMs == null) return; + final latest = _latestObservedMonotonicMs; + if (latest == null || monotonicMs > latest) { + _latestObservedMonotonicMs = monotonicMs; + } + // Real GPS fix evidence: the fix timestamp is present. + if (sample.gpsTimestamp != null) _lastGpsEvidenceMonotonicMs = monotonicMs; + // Real IMU sample evidence: the IMU timestamp is present. + if (sample.imuTimestamp != null) _lastImuEvidenceMonotonicMs = monotonicMs; + _lastAcceptedTelemetryTime = DateTime.now(); + } + + /// Recomputes the ONE authoritative RecordingHealthContract from real evidence + /// and maps it to RecordingHealthStatus. Invoked only from the canonical + /// telemetry-acceptance and watchdog paths - never from a bare timer tick, a UI + /// frame, or a service-presence assumption. + /// + /// RID-3B-1 priority is preserved: `resolveStatus` treats recordingFinalizing, + /// recordingComplete and recordingFailed as authoritative lifecycle overrides, + /// so ordinary health evaluation can never return a finishing or finished ride + /// to recordingActive. + void _evaluateRecordingHealth() { + final now = DateTime.now(); + const sensorBound = RecordingHealthContract.evidenceStaleAfterMs; + + // A. Active, valid ride/session identity. + final hasValidSession = + state.dbId != null && state.state == RideState.riding; + + // C. Telemetry evidence, using the SAME 4 s bound as the existing canonical + // watchdog so contract and status agree by construction. This wall-clock term + // is also what prevents monotonic freeze-blindness: when samples stop, the + // monotonic reference stops advancing, but this term still goes stale. + final accepted = _lastAcceptedTelemetryTime; + final telemetryAgeMs = + accepted == null ? null : now.difference(accepted).inMilliseconds; + // Fail closed on a backwards clock jump: negative age is not freshness. + final telemetryActive = telemetryAgeMs != null && + telemetryAgeMs >= 0 && + telemetryAgeMs <= sensorBound; + + // B. GPS and D. IMU evidence: monotonic, presence-proven. + final gpsOperational = + _isFresh(_monotonicAgeMs(_lastGpsEvidenceMonotonicMs), sensorBound); + final sensorActive = + _isFresh(_monotonicAgeMs(_lastImuEvidenceMonotonicMs), sensorBound); + + // E. Database evidence: advances ONLY after a real successful telemetry + // commit (the RID-3B-2 commit point). A scheduled flush is not evidence, and + // a failed pre-commit batch does not advance it. + final dbOperational = !_dbPersistenceFailed && + _isFresh(_monotonicAgeMs(_lastDbCommitMonotonicMs), + RecordingHealthContract.databaseWriteStaleAfterMs); + + final previous = state.health; + final health = previous.copyWith( + isForegroundServiceAlive: gpsOperational, + isTelemetryPipelineActive: telemetryActive, + isAtLeastOneSensorActive: sensorActive, + isDatabasePersistenceOperational: dbOperational, + isValidSessionId: hasValidSession, + lastGPSFixTime: previous.lastGPSFixTime, + lastTelemetryTime: accepted ?? previous.lastTelemetryTime, + lastIMUSampleTime: previous.lastIMUSampleTime, + lastDatabaseWriteTime: previous.lastDatabaseWriteTime, + ); + + state = state.copyWith( + health: health, + healthStatus: health.resolveStatus(lifecycle: state.healthStatus), + ); + } + void _checkWatchdog() { if (state.state != RideState.riding) return; final now = DateTime.now(); - if (_lastTelemetryReceivedTime != null) { - final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds; - if (elapsedSec > 4) { - // Telemetry pipeline has stalled - if (state.healthStatus != RecordingHealthStatus.recordingDegraded) { - state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded); - try { - ref.read(fieldTestLoggerProvider).log( - eventType: FieldTestEventType.telemetryGap, - severity: EventSeverity.warning, - message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)', - ); - } catch (_) {} - } - } + // Existing canonical stall detection: the 4-second bound and the telemetryGap + // diagnostic are unchanged. RID-3C-1 adds NO second watchdog; it makes this + // one the source of heartbeat and consecutive-miss evidence for the contract. + final elapsedSec = _lastTelemetryReceivedTime == null + ? null + : now.difference(_lastTelemetryReceivedTime!).inSeconds; + final stalled = elapsedSec != null && elapsedSec > 4; + + final previousMisses = state.health.consecutiveWatchdogMisses; + final misses = stalled ? previousMisses + 1 : 0; + + if (stalled && previousMisses == 0) { + // First detection of this stall episode: logged once, as before. + try { + ref.read(fieldTestLoggerProvider).log( + eventType: FieldTestEventType.telemetryGap, + severity: EventSeverity.warning, + message: 'Watchdog: Telemetry pipeline stalled ($elapsedSec seconds without samples)', + ); + } catch (_) {} } + + state = state.copyWith( + health: state.health.copyWith( + lastHeartbeatTime: now, + consecutiveWatchdogMisses: misses, + ), + ); + + // The watchdog evaluates the contract instead of maintaining a contradictory + // parallel truth, so RecordingHealthStatus and RecordingHealthContract agree. + _evaluateRecordingHealth(); } Future _recoverInterruptedRides() async { @@ -292,10 +443,16 @@ class RideStateMachine extends StateNotifier { motorcycleVariant: defaultBike?.variantName, ); + // RID-3C-1 (warm-up): a freshly created ride has NO evidence yet, so it must + // not claim recordingActive. Ride creation is never blocked waiting for GPS, + // IMU or the 3-second batch; health warms up automatically as real evidence + // arrives and _evaluateRecordingHealth promotes the status. + _resetHealthEvidence(); state = state.copyWith( dbId: id, state: RideState.riding, - healthStatus: RecordingHealthStatus.recordingActive, + healthStatus: RecordingHealthStatus.recordingArmed, + health: const RecordingHealthContract(), startTime: startTime, tripDistanceMiles: 0, topSpeedMph: 0, @@ -317,6 +474,12 @@ class RideStateMachine extends StateNotifier { _stoppedTimeAccumulator = 0.0; _lastTelemetryReceivedTime = DateTime.now(); + // RID-3C-1: publish truthful warm-up health immediately, so the contract + // reflects the real session identity even before the first sample arrives. + // All evidence is still absent, so this resolves to recordingArmed and can + // never produce a false recordingActive. + _evaluateRecordingHealth(); + try { WakelockPlus.enable().catchError((_) {}); } catch (_) {} @@ -362,9 +525,14 @@ class RideStateMachine extends StateNotifier { } void resumeRide() { - state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive); + // RID-3C-1: resuming does not by itself prove recording health. Evidence ages + // out monotonically across the pause, so the next accepted sample + // re-establishes truthfully whether the ride is active or degraded. + state = state.copyWith(state: RideState.riding, healthStatus: RecordingHealthStatus.recordingArmed); _lastSample = null; // reset to avoid jumping _lastTelemetryReceivedTime = DateTime.now(); + // RID-3C-1: re-publish truthful health for the resumed session. + _evaluateRecordingHealth(); try { WakelockPlus.enable().catchError((_) {}); } catch (_) {} @@ -526,12 +694,18 @@ class RideStateMachine extends StateNotifier { // every subsequent ride continue to record normally. if (_telemetryAcceptanceClosed) return; - // RID-3B-1: incoming telemetry must not clobber recordingFinalizing back to - // recordingActive while finalization is in progress. Minimum private - // condition: the existing RID-2 in-flight finish flag. - if (!_isFinishing && - state.healthStatus != RecordingHealthStatus.recordingActive) { - state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive); + // RID-3C-1: an accepted sample is EVIDENCE, not a health verdict. Record only + // what it actually proves - a real GPS fix timestamp, a real IMU sample + // timestamp, monotonic timing - and let the contract decide the status. + // + // RID-3B-1 is preserved twice over: this runs AFTER the acceptance cutoff gate + // above, so a post-cutoff sample can never refresh health evidence, and the + // existing RID-2 `_isFinishing` guard together with `resolveStatus`'s + // lifecycle overrides still prevent recordingFinalizing from being clobbered + // back to recordingActive. + _recordTelemetryEvidence(sample); + if (!_isFinishing) { + _evaluateRecordingHealth(); } _queueForBatch(sample); @@ -699,6 +873,10 @@ class RideStateMachine extends StateNotifier { // arrived during the await, so a later legitimate flush can retry them. // No automatic retry worker is added. _telemetryBatch.insertAll(0, batchToInsert); + // RID-3C-1: a pre-commit failure means this telemetry is NOT durable, so + // database health evidence must not advance and is marked non-operational + // until a real commit succeeds. + _dbPersistenceFailed = true; // Keep in queue for retry on next flush interval. A notifier that has // begun disposal must not touch ref, so the diagnostic is skipped in that // case; it remains bounded either way. @@ -733,7 +911,16 @@ class RideStateMachine extends StateNotifier { // consistent, and no database counter or schema field is added. if (mounted) { try { - state = state.copyWith(totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length); + // RID-3C-1: database health evidence advances ONLY here, strictly after + // the RID-3B-2 commit point. A scheduled flush is not evidence, and a + // failed pre-commit batch never reaches this block. + _lastDbCommitMonotonicMs = _latestObservedMonotonicMs; + _dbPersistenceFailed = false; + state = state.copyWith( + totalSamplesPersisted: state.totalSamplesPersisted + batchToInsert.length, + health: state.health.copyWith(lastDatabaseWriteTime: DateTime.now()), + ); + _evaluateRecordingHealth(); } catch (_) { // Post-commit bookkeeping failure must never be classified as a // persistence failure and must never restore the committed snapshot. diff --git a/lib/models/recording_health_status.dart b/lib/models/recording_health_status.dart index c1c89c0..74bef02 100644 --- a/lib/models/recording_health_status.dart +++ b/lib/models/recording_health_status.dart @@ -39,6 +39,21 @@ enum RecordingHealthStatus { /// Strict Recording Health Contract state tracker (Phase 5) class RecordingHealthContract { final RecordingHealthStatus status; + /// RID-3C-1 SEMANTICS - READ BEFORE USE. + /// + /// There is NO Dart API that truthfully reports "the native + /// `GeolocatorLocationService` is alive". `geolocator_android` exposes no + /// method-channel query for its foreground service, and its + /// `ForegroundNotificationOptions` carry no state. This field is therefore an + /// EVIDENCE-DERIVED OPERATIONAL PROXY meaning exactly: + /// + /// "foreground location delivery appears operational for the active ride" + /// + /// It is set from real, recent GPS fix evidence flowing through the existing + /// location pipeline, and is NOT set merely because a + /// `ForegroundNotificationConfig` exists, permission was granted, + /// `LocationService` was constructed, or `getPositionStream` was called. + /// Native service observability is deliberately not fabricated. final bool isForegroundServiceAlive; final bool isTelemetryPipelineActive; final bool isAtLeastOneSensorActive; @@ -111,4 +126,92 @@ class RecordingHealthContract { isAtLeastOneSensorActive && isDatabasePersistenceOperational && isValidSessionId; + + // --------------------------------------------------------------------------- + // RID-3C-1: freshness bounds and status mapping. + // + // Every bound is derived from an EXISTING production cadence or watchdog + // constant so the contract and the canonical watchdog cannot disagree. Each + // exists exactly once, is named, and is covered by deterministic boundary + // tests. No bound is copied from design notes without a production basis. + // --------------------------------------------------------------------------- + + /// Evidence staleness bound for the sensor domains (GPS, telemetry, IMU). + /// + /// Reuses the EXISTING `_checkWatchdog` pipeline-stall threshold of 4 seconds + /// (`elapsedSec > 4`) so `RecordingHealthStatus` and this contract agree by + /// construction rather than by coincidence. Against production cadence this is + /// ~40 consecutive missed GPS fixes at the 10 Hz target and ~200 missed IMU + /// samples at 50 Hz, i.e. far beyond normal jitter, while `LocationService`'s + /// 1 s watchdog already actively re-polls after 2 s without a fix. + static const int evidenceStaleAfterMs = 4000; + + /// Database persistence staleness bound. + /// + /// 3x the EXISTING 3-second `_batchWriteTimer` crash-durability cadence. Two + /// consecutive missed batches are tolerated because a single late batch is + /// ordinary jitter; the third absence means telemetry is genuinely not being + /// committed. + static const int databaseWriteStaleAfterMs = 9000; + + /// Consecutive watchdog misses tolerated before health is not reportable. + /// + /// The EXISTING watchdog cadence is 2 seconds, so 2 misses is ~4 seconds and + /// matches [evidenceStaleAfterMs]. + static const int watchdogMissLimit = 2; + + /// True while the canonical watchdog has not accumulated a stall. + bool get isWatchdogHealthy => consecutiveWatchdogMisses < watchdogMissLimit; + + /// True when no evidence of any kind has been observed yet for this session. + /// + /// Distinguishes "ride created, evidence still warming up" from "evidence was + /// established and has since gone stale", so warm-up maps to + /// [RecordingHealthStatus.recordingArmed] instead of falsely implying a fault. + bool get isEvidenceWarmUp => + lastGPSFixTime == null && + lastTelemetryTime == null && + lastIMUSampleTime == null && + lastDatabaseWriteTime == null; + + /// RID-3C-1: the ONE deterministic contract -> status mapping. + /// + /// [lifecycle] is the status the ride lifecycle itself owns (finalizing, + /// complete, failed). Those are authoritative overrides: ordinary telemetry + /// health evaluation must never return a finalizing or completed ride to + /// `recordingActive`, and `recordingComplete` stays complete. + /// + /// Guarantee: `canReportRecording == true` MAY show `recordingActive`; + /// `canReportRecording == false` MUST NOT show `recordingActive`. + RecordingHealthStatus resolveStatus({ + required RecordingHealthStatus lifecycle, + }) { + switch (lifecycle) { + case RecordingHealthStatus.recordingFinalizing: + return RecordingHealthStatus.recordingFinalizing; + case RecordingHealthStatus.recordingComplete: + return RecordingHealthStatus.recordingComplete; + case RecordingHealthStatus.recordingFailed: + return RecordingHealthStatus.recordingFailed; + case RecordingHealthStatus.recordingArmed: + case RecordingHealthStatus.recordingStarting: + case RecordingHealthStatus.recordingActive: + case RecordingHealthStatus.recordingDegraded: + break; + } + + // No active ride identity: nothing may claim recording, however healthy the + // location stack or sensors look. Service presence alone is never recording. + if (!isValidSessionId) return RecordingHealthStatus.recordingArmed; + + if (canReportRecording && isWatchdogHealthy) { + return RecordingHealthStatus.recordingActive; + } + + // Evidence has not begun arriving yet: warm-up, not a fault. + if (isEvidenceWarmUp) return RecordingHealthStatus.recordingArmed; + + return RecordingHealthStatus.recordingDegraded; + } + } diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 754daec..230ad0e 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -121,8 +121,26 @@ class LocationService { intervalDuration: const Duration(milliseconds: 100), forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness foregroundNotificationConfig: const ForegroundNotificationConfig( - notificationText: 'MotorSignal is recording ride telemetry', - notificationTitle: 'MotorSignal Active Ride', + // RID-3C-1: permanently truthful wording. + // + // The geolocator foreground service is APP-LIFETIME by design (see + // RID-3C-1 binding decision 2): it starts when the location stack is + // created and is deliberately NOT ride-scoped, because ride-scoping + // would introduce Android 12+ background-FGS-start failures and + // service/wake-lock churn - especially with motion auto-start. + // + // The previous copy claimed an "Active Ride" and "recording ride + // telemetry" while merely idling on the dashboard, which was false. + // This text must describe the app-lifetime location CAPABILITY only, + // never current ride state. It is static by design: it must not be + // updated dynamically, and the stream must not be resubscribed just + // to change it. + // + // Must never contain: "Active Ride", "currently recording", a ride + // name, route, coordinates, speed, distance, telemetry values, or + // account identity. + notificationText: 'Location active for ride recording', + notificationTitle: 'MotorSignal', enableWakeLock: true, ), ); diff --git a/test/forensic_lifecycle_audit_test.dart b/test/forensic_lifecycle_audit_test.dart index aeaf565..4916237 100644 --- a/test/forensic_lifecycle_audit_test.dart +++ b/test/forensic_lifecycle_audit_test.dart @@ -117,7 +117,7 @@ void main() { await db.close(); }); - test('Physical Failure Regression: Starting ride creates DB session, active health, and logs lifecycle', () async { + test('Physical Failure Regression: Starting ride creates DB session, warm-up health, and logs lifecycle', () async { final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), @@ -141,7 +141,13 @@ void main() { session = container.read(rideStateMachineProvider); expect(session.state, equals(RideState.riding)); expect(session.dbId, isNotNull); - expect(session.healthStatus, equals(RecordingHealthStatus.recordingActive)); + // RID-3C-1: a freshly created ride has NO evidence yet, so it must report + // warm-up health instead of falsely claiming an active recording. Health + // promotes automatically once real GPS / IMU / telemetry / DB-commit evidence + // arrives; see test/rid3c1_truthful_recording_health_test.dart (T1, T2). + expect(session.healthStatus, equals(RecordingHealthStatus.recordingArmed)); + expect(session.health.canReportRecording, isFalse); + expect(session.health.isValidSessionId, isTrue); // Emit 10 telemetry samples for (int i = 0; i < 10; i++) { diff --git a/test/rid3c1_truthful_recording_health_test.dart b/test/rid3c1_truthful_recording_health_test.dart new file mode 100644 index 0000000..459c6e0 --- /dev/null +++ b/test/rid3c1_truthful_recording_health_test.dart @@ -0,0 +1,632 @@ +// RID-3C-1 permanent regression suite: truthful, evidence-based recording health. +// +// Locks: no false "recording active" without real evidence; GPS / telemetry / +// IMU / database / watchdog evidence domains; monotonic freshness; warm-up, +// degradation and recovery; RID-3B-1 finalizing priority; post-cutoff samples +// cannot refresh health; and the permanently truthful foreground notification. +// +// Evidence is controlled through TelemetrySample.monotonicTimestampMs, so +// staleness is deterministic and no test relies on a sleep-only race except where +// the production wall-clock telemetry bound is itself the subject (T4/T9). +import 'dart:async'; +import 'dart:io'; + +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:drift/drift.dart' as drift; +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_event.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'; + +class _Rid3c1AppDatabase extends AppDatabase { + _Rid3c1AppDatabase() : super.forTesting(NativeDatabase.memory()); + + int batchInvocations = 0; + bool gateBatch = false; + final List> batchGates = []; + int batchFailuresRemaining = 0; + + @override + Future batch( + FutureOr Function(drift.Batch batch) runInBatch) async { + batchInvocations++; + if (gateBatch) { + final gate = Completer(); + batchGates.add(gate); + await gate.future; + } + if (batchFailuresRemaining > 0) { + batchFailuresRemaining--; + throw StateError('RID-3C-1 simulated pre-commit persistence failure'); + } + return super.batch(runInBatch); + } +} + +class _Rid3c1FieldTestLogger extends FieldTestLogger { + _Rid3c1FieldTestLogger(super.db); + + final List eventTypes = []; + + int countOf(FieldTestEventType type) => + eventTypes.where((t) => t == type).length; + + /// Neutralised so db.batch accounting refers only to telemetry persistence. + @override + Future flush() async {} + + @override + void log({ + required FieldTestEventType eventType, + required EventSeverity severity, + required String message, + int? rideId, + DateTime? timestamp, + DateTime? telemetryTimestamp, + int? gpsAgeMs, + double? speed, + double? latitude, + double? longitude, + double? gpsAccuracy, + double? leanAngle, + double? longitudinalG, + double? lateralG, + double? overallConfidence, + Map? metadata, + }) { + eventTypes.add(eventType); + super.log( + eventType: eventType, + severity: severity, + message: message, + rideId: rideId, + timestamp: timestamp, + telemetryTimestamp: telemetryTimestamp, + gpsAgeMs: gpsAgeMs, + speed: speed, + latitude: latitude, + longitude: longitude, + gpsAccuracy: gpsAccuracy, + leanAngle: leanAngle, + longitudinalG: longitudinalG, + lateralG: lateralG, + overallConfidence: overallConfidence, + metadata: metadata, + ); + } +} + +class _Rid3c1AudioAlertService extends Fake implements AudioAlertService { + @override + Future speak(String text, {bool force = false}) async {} +} + +class _Rid3c1TelemetryService extends TelemetryService { + _Rid3c1TelemetryService(super.loc, super.fusion, super.compass, super.logger, + super.validator); + + final StreamController _controller = + StreamController.broadcast(); + + @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(); + + /// The production batch-write timer is a real 3-second Timer.periodic; waiting + /// just over one interval makes the DB-commit evidence deterministic without + /// mocking the frozen crash-durability cadence. + const tickWait = Duration(milliseconds: 3300); + + /// Production wall-clock bounds under test. Referenced from the contract so a + /// threshold change is caught by these tests rather than silently drifting. + const evidenceStale = RecordingHealthContract.evidenceStaleAfterMs; + const watchdogLimit = RecordingHealthContract.watchdogMissLimit; + + late _Rid3c1AppDatabase db; + late _Rid3c1FieldTestLogger logger; + late _Rid3c1TelemetryService telemetry; + late SharedPreferences prefs; + + final sampleBase = DateTime(2026, 9, 10, 12); + var identity = 0; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = _Rid3c1AppDatabase(); + logger = _Rid3c1FieldTestLogger(db); + telemetry = _Rid3c1TelemetryService( + _NullLocationService(), + _NullSensorFusionService(), + _NullCompassService(), + logger, + TelemetryValidator()); + identity = 0; + }); + + tearDown(() async { + for (final gate in db.batchGates) { + if (!gate.isCompleted) gate.complete(); + } + db.batchGates.clear(); + db.gateBatch = false; + db.batchFailuresRemaining = 0; + telemetry.dispose(); + logger.dispose(); + await db.close(); + }); + + ProviderContainer buildContainer({bool registerTeardown = true}) { + final container = ProviderContainer(overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + telemetryServiceProvider.overrideWithValue(telemetry), + audioAlertServiceProvider.overrideWithValue(_Rid3c1AudioAlertService()), + fieldTestLoggerProvider.overrideWithValue(logger), + ]); + if (registerTeardown) addTearDown(container.dispose); + return container; + } + + RideSession sessionOf(ProviderContainer c) => c.read(rideStateMachineProvider); + + Future startRide(ProviderContainer c) => + c.read(rideStateMachineProvider.notifier).startRide(); + + /// Emits samples carrying REAL evidence markers. + /// + /// [monotonicMs] is the sample's position in TelemetryService's monotonic + /// domain, which is how staleness is controlled deterministically. GPS and IMU + /// evidence are PRESENCE signals (`gpsTimestamp` / `imuTimestamp`), never + /// magnitudes: the lean/pitch/g values below are deliberately 0.0 to prove a + /// genuine zero-valued sensor sample still counts as real evidence. + void emitEvidence({ + required int monotonicMs, + int count = 4, + bool withGps = true, + bool withImu = true, + int stepMs = 50, + }) { + for (var i = 0; i < count; i++) { + final index = identity++; + final ts = sampleBase.add(Duration(milliseconds: index * 50)); + telemetry.emitSample(TelemetrySample( + timestamp: ts, + sequenceNumber: index, + monotonicTimestampMs: monotonicMs + (i * stepMs), + latitude: 34.0 + (index * 0.0001), + longitude: -85.0, + speed: 42.0, + heading: 90.0, + gpsAccuracy: 3.0, + gpsTimestamp: withGps ? ts : null, + imuTimestamp: withImu ? ts : null, + leanAngle: 0.0, + pitchAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + )); + } + } + + /// Drives the pipeline to a fully evidenced, healthy active ride: valid session, + /// fresh GPS, fresh telemetry, fresh real IMU evidence, and one REAL successful + /// telemetry commit at the RID-3B-2 commit point. + Future reachHealthyActive(ProviderContainer c) async { + await startRide(c); + emitEvidence(monotonicMs: 1000); + await pumpEventQueue(times: 30); + await Future.delayed(tickWait); + await pumpEventQueue(times: 40); + // Re-establish GPS/IMU freshness relative to the newest monotonic reference. + emitEvidence(monotonicMs: 1000 + tickWait.inMilliseconds); + await pumpEventQueue(times: 30); + } + + group('RID-3C-1 truthful recording health', () { + test('T1 no false recordingActive at ride creation before evidence', + () async { + final c = buildContainer(); + await startRide(c); + final s = sessionOf(c); + expect(s.state, RideState.riding, + reason: 'ride creation is never blocked on GPS/IMU/DB warm-up'); + expect(s.dbId, isNotNull); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive)); + expect(s.healthStatus, RecordingHealthStatus.recordingArmed, + reason: 'warm-up, not a fault'); + expect(s.health.isEvidenceWarmUp, isTrue); + expect(s.health.isValidSessionId, isTrue); + }); + + test('T2 complete real evidence becomes recordingActive', () async { + final c = buildContainer(); + await reachHealthyActive(c); + final s = sessionOf(c); + expect(s.health.isValidSessionId, isTrue); + expect(s.health.isForegroundServiceAlive, isTrue, + reason: 'real recent GPS fix evidence, not a service assumption'); + expect(s.health.isTelemetryPipelineActive, isTrue); + expect(s.health.isAtLeastOneSensorActive, isTrue, + reason: 'real IMU sample present although lean/pitch/g were 0.0'); + expect(s.health.isDatabasePersistenceOperational, isTrue, + reason: 'a real telemetry commit occurred'); + expect(s.health.lastDatabaseWriteTime, isNotNull); + expect(s.health.consecutiveWatchdogMisses, lessThan(watchdogLimit)); + expect(s.health.canReportRecording, isTrue); + expect(s.healthStatus, RecordingHealthStatus.recordingActive); + expect(db.batchInvocations, greaterThanOrEqualTo(1)); + }); + + test('T3 stale GPS evidence alone degrades an active ride', () async { + final c = buildContainer(); + await reachHealthyActive(c); + expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive); + + // Advance monotonic time past the GPS bound but still inside the DB bound, + // and stop supplying GPS fixes. Only the GPS domain goes stale. + final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 1000; + emitEvidence(monotonicMs: advanced, withGps: false); + await pumpEventQueue(times: 30); + + final s = sessionOf(c); + expect(s.health.isForegroundServiceAlive, isFalse); + expect(s.health.isTelemetryPipelineActive, isTrue); + expect(s.health.isAtLeastOneSensorActive, isTrue); + expect(s.health.isDatabasePersistenceOperational, isTrue); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, RecordingHealthStatus.recordingDegraded); + expect(s.state, RideState.riding, + reason: 'degradation must never end the ride'); + }); + test('T7 real sensor evidence required; zero magnitude is not missing', + () async { + final c = buildContainer(); + await reachHealthyActive(c); + expect(sessionOf(c).health.isAtLeastOneSensorActive, isTrue, + reason: 'lean/pitch/g were all 0.0 yet the sample is real evidence'); + + final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 1000; + emitEvidence(monotonicMs: advanced, withImu: false); + await pumpEventQueue(times: 30); + + final s = sessionOf(c); + expect(s.health.isAtLeastOneSensorActive, isFalse); + expect(s.health.isForegroundServiceAlive, isTrue, + reason: 'GPS still fresh: only the IMU domain was expired'); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive)); + }); + + test('T8 no valid active session means no recording claim', () async { + final c = buildContainer(); + final s = sessionOf(c); + expect(s.state, RideState.idle); + expect(s.dbId, isNull); + expect(s.health.isValidSessionId, isFalse); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive)); + }); + + test('T14 location stack available without a ride is never recording', + () async { + final c = buildContainer(); + // Full-evidence telemetry flows but no ride exists, so the acceptance path + // records nothing: service and sensor presence alone claim nothing. + emitEvidence(monotonicMs: 5000, count: 10); + await pumpEventQueue(times: 40); + final s = sessionOf(c); + expect(s.state, isNot(RideState.riding)); + expect(s.health.isValidSessionId, isFalse); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive)); + expect(s.health.isEvidenceWarmUp, isTrue); + }); + + test('T13 foreground notification copy is truthful and non-sensitive', + () async { + // The notification strings are compile-time constants inside a private + // method of LocationService with no behavioural seam, so this is a + // deliberate source-text assertion on the extracted literals only. + final source = + await File('lib/services/location_service.dart').readAsString(); + final title = RegExp(r"notificationTitle:\s*'([^']*)'") + .firstMatch(source) + ?.group(1); + final body = RegExp(r"notificationText:\s*'([^']*)'") + .firstMatch(source) + ?.group(1); + expect(title, isNotNull, reason: 'production notification title exists'); + expect(body, isNotNull, reason: 'production notification body exists'); + expect(title, 'MotorSignal'); + expect(body, 'Location active for ride recording'); + + for (final value in [title!, body!]) { + final lower = value.toLowerCase(); + expect(lower, isNot(contains('active ride')), + reason: 'must never imply a ride is active while idle'); + expect(lower, isNot(contains('currently recording'))); + expect(lower, isNot(contains('recording ride telemetry'))); + for (final banned in [ + 'latitude', + 'longitude', + 'coordinate', + 'speed', + 'distance', + 'route', + 'uid', + 'token', + '@', + ]) { + expect(lower, isNot(contains(banned)), + reason: 'no sensitive ride or account data in notification copy'); + } + } + }); + test('T4 stale telemetry evidence degrades an active ride', () async { + final c = buildContainer(); + await reachHealthyActive(c); + expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive); + + // Telemetry freshness is the one wall-clock term, deliberately sharing the + // existing watchdog bound: it is what prevents monotonic freeze-blindness + // when samples stop arriving and the monotonic reference stops advancing. + await Future.delayed(Duration(milliseconds: evidenceStale + 1200)); + await pumpEventQueue(times: 20); + + final s = sessionOf(c); + expect(s.health.isTelemetryPipelineActive, isFalse); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, RecordingHealthStatus.recordingDegraded); + expect(s.state, RideState.riding, + reason: 'degradation never ends the ride or fabricates a failure'); + }); + + test('T9 canonical watchdog misses degrade health', () async { + final c = buildContainer(); + await reachHealthyActive(c); + expect(sessionOf(c).health.consecutiveWatchdogMisses, 0); + + // Long enough for the existing 2 s watchdog to accumulate >= limit misses + // after the 4 s stall bound is crossed, with margin for tick phase offset. + // No second watchdog is created. + await Future.delayed(Duration(milliseconds: evidenceStale + 7000)); + await pumpEventQueue(times: 20); + + final s = sessionOf(c); + expect(s.health.lastHeartbeatTime, isNotNull, + reason: 'the canonical watchdog supplies heartbeat evidence'); + expect(s.health.consecutiveWatchdogMisses, + greaterThanOrEqualTo(watchdogLimit)); + expect(s.health.isWatchdogHealthy, isFalse); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, RecordingHealthStatus.recordingDegraded); + expect(logger.countOf(FieldTestEventType.telemetryGap), + greaterThanOrEqualTo(1), + reason: 'the existing stall diagnostic is preserved'); + }); + + test('T5 pre-commit DB failure does not advance database evidence', + () async { + final c = buildContainer(); + await startRide(c); + emitEvidence(monotonicMs: 1000); + await pumpEventQueue(times: 30); + + db.batchFailuresRemaining = 1; + await Future.delayed(tickWait); + await pumpEventQueue(times: 40); + + final rows = await db.select(db.telemetrySamples).get(); + final s = sessionOf(c); + expect(db.batchInvocations, 1); + expect(rows, isEmpty, reason: 'the failed attempt committed nothing'); + expect(s.health.isDatabasePersistenceOperational, isFalse, + reason: 'a failed pre-commit batch must not advance DB health'); + expect(s.health.lastDatabaseWriteTime, isNull); + expect(s.health.canReportRecording, isFalse); + expect(s.healthStatus, isNot(RecordingHealthStatus.recordingActive), + reason: 'no false recording claim without durable telemetry'); + }); + test('T6 a successful retry restores database evidence', () async { + final c = buildContainer(); + await startRide(c); + emitEvidence(monotonicMs: 1000); + await pumpEventQueue(times: 30); + + db.batchFailuresRemaining = 1; + await Future.delayed(tickWait); + await pumpEventQueue(times: 40); + expect(sessionOf(c).health.isDatabasePersistenceOperational, isFalse); + + // RID-3A front-restoration retries the same snapshot; this time it commits. + final second = 1000 + tickWait.inMilliseconds; + emitEvidence(monotonicMs: second); + await pumpEventQueue(times: 30); + await Future.delayed(tickWait); + await pumpEventQueue(times: 40); + + final rows = await db.select(db.telemetrySamples).get(); + expect(rows, isNotEmpty, reason: 'the retry really committed'); + + // Refresh telemetry/GPS/IMU freshness relative to the newest monotonic + // reference so recovery is attributed to evidence, not to timing luck. + emitEvidence(monotonicMs: second + tickWait.inMilliseconds); + await pumpEventQueue(times: 30); + + final s = sessionOf(c); + expect(s.health.isDatabasePersistenceOperational, isTrue, + reason: 'a successful retry restores DB-health evidence'); + expect(s.health.lastDatabaseWriteTime, isNotNull); + expect(s.health.isTelemetryPipelineActive, isTrue); + expect(s.health.isForegroundServiceAlive, isTrue); + expect(s.health.isAtLeastOneSensorActive, isTrue); + expect(s.health.canReportRecording, isTrue, + reason: 'active ONLY because every domain is fresh again'); + expect(s.healthStatus, RecordingHealthStatus.recordingActive); + }); + + test('T10 health recovers to active without creating a new ride', () async { + final c = buildContainer(); + await reachHealthyActive(c); + final rideId = sessionOf(c).dbId; + expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingActive); + + // Expire only GPS while keeping the DB commit inside its 9 s bound, so the + // recovery step is attributable to GPS evidence alone. + final advanced = 1000 + tickWait.inMilliseconds + evidenceStale + 200; + emitEvidence(monotonicMs: advanced, withGps: false); + await pumpEventQueue(times: 30); + expect(sessionOf(c).healthStatus, RecordingHealthStatus.recordingDegraded); + + // GPS evidence returns: recovery needs no new ride, no dbId change and no + // service or TelemetryService restart. + emitEvidence(monotonicMs: advanced + 300); + await pumpEventQueue(times: 30); + + final s = sessionOf(c); + expect(s.health.canReportRecording, isTrue); + expect(s.healthStatus, RecordingHealthStatus.recordingActive); + expect(s.dbId, rideId, reason: 'same ride identity throughout'); + expect(s.state, RideState.riding); + }); + test('T11 finalizing/complete/failed cannot be clobbered by health ' + 'evaluation', () { + // Deterministic contract-level proof of RID-3B-1 priority: the lifecycle + // status owned by finalization is authoritative over evidence mapping. + const healthy = RecordingHealthContract( + isForegroundServiceAlive: true, + isTelemetryPipelineActive: true, + isAtLeastOneSensorActive: true, + isDatabasePersistenceOperational: true, + isValidSessionId: true, + ); + expect(healthy.canReportRecording, isTrue); + expect( + healthy.resolveStatus( + lifecycle: RecordingHealthStatus.recordingFinalizing), + RecordingHealthStatus.recordingFinalizing); + expect( + healthy.resolveStatus( + lifecycle: RecordingHealthStatus.recordingComplete), + RecordingHealthStatus.recordingComplete); + expect( + healthy.resolveStatus( + lifecycle: RecordingHealthStatus.recordingFailed), + RecordingHealthStatus.recordingFailed); + + // A ride with a session but incomplete evidence may NEVER map to active. + final incomplete = RecordingHealthContract( + isValidSessionId: true, + lastTelemetryTime: DateTime(2026, 9, 10, 12), + ); + expect(incomplete.canReportRecording, isFalse); + expect(incomplete.isEvidenceWarmUp, isFalse); + expect( + incomplete.resolveStatus( + lifecycle: RecordingHealthStatus.recordingActive), + RecordingHealthStatus.recordingDegraded, + reason: 'canReportRecording == false MUST NOT show recordingActive'); + + // Watchdog misses alone withhold an active claim WITHOUT weakening the + // frozen five-way conjunction. + final missed = healthy.copyWith( + consecutiveWatchdogMisses: RecordingHealthContract.watchdogMissLimit, + ); + expect(missed.canReportRecording, isTrue, + reason: 'the frozen conjunction is preserved exactly'); + expect(missed.isWatchdogHealthy, isFalse); + expect( + missed.resolveStatus( + lifecycle: RecordingHealthStatus.recordingActive), + isNot(RecordingHealthStatus.recordingActive)); + }); + + test('T12 late telemetry cannot refresh health evidence', () async { + final c = buildContainer(); + await reachHealthyActive(c); + final notifier = c.read(rideStateMachineProvider.notifier); + + await notifier.finishRide(); + await pumpEventQueue(times: 40); + expect(sessionOf(c).healthStatus, + RecordingHealthStatus.recordingComplete); + + final before = sessionOf(c).health; + // Late samples are rejected by RID-3B-1's acceptance cutoff and by the + // riding-state guard, and resolveStatus keeps complete authoritative. + emitEvidence(monotonicMs: 999000, count: 10); + await pumpEventQueue(times: 40); + + final after = sessionOf(c).health; + expect(sessionOf(c).healthStatus, + RecordingHealthStatus.recordingComplete, + reason: 'complete stays complete'); + expect(after.lastTelemetryTime, before.lastTelemetryTime, + reason: 'late telemetry must not refresh health evidence'); + expect(after.isTelemetryPipelineActive, before.isTelemetryPipelineActive); + expect(after.isForegroundServiceAlive, before.isForegroundServiceAlive); + expect(after.isAtLeastOneSensorActive, before.isAtLeastOneSensorActive); + expect(sessionOf(c).state, RideState.finished); + }); + }); +}