fix: make recording health evidence-based and notification truthful
This commit is contained in:
parent
8681e6f259
commit
ec759ea3c5
|
|
@ -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<RideSession> {
|
|||
_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<void> _recoverInterruptedRides() async {
|
||||
|
|
@ -292,10 +443,16 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
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<RideSession> {
|
|||
_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<RideSession> {
|
|||
}
|
||||
|
||||
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<RideSession> {
|
|||
// 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<RideSession> {
|
|||
// 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<RideSession> {
|
|||
// 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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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<Completer<void>> batchGates = [];
|
||||
int batchFailuresRemaining = 0;
|
||||
|
||||
@override
|
||||
Future<void> batch(
|
||||
FutureOr<void> Function(drift.Batch batch) runInBatch) async {
|
||||
batchInvocations++;
|
||||
if (gateBatch) {
|
||||
final gate = Completer<void>();
|
||||
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<FieldTestEventType> eventTypes = [];
|
||||
|
||||
int countOf(FieldTestEventType type) =>
|
||||
eventTypes.where((t) => t == type).length;
|
||||
|
||||
/// Neutralised so db.batch accounting refers only to telemetry persistence.
|
||||
@override
|
||||
Future<void> 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<String, dynamic>? 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<void> speak(String text, {bool force = false}) async {}
|
||||
}
|
||||
|
||||
class _Rid3c1TelemetryService extends TelemetryService {
|
||||
_Rid3c1TelemetryService(super.loc, super.fusion, super.compass, super.logger,
|
||||
super.validator);
|
||||
|
||||
final StreamController<TelemetrySample> _controller =
|
||||
StreamController<TelemetrySample>.broadcast();
|
||||
|
||||
@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();
|
||||
|
||||
/// 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<void> 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<void> reachHealthyActive(ProviderContainer c) async {
|
||||
await startRide(c);
|
||||
emitEvidence(monotonicMs: 1000);
|
||||
await pumpEventQueue(times: 30);
|
||||
await Future<void>.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<void>.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<void>.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<void>.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<void>.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<void>.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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue