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 int? dbId;
|
||||||
final RideState state;
|
final RideState state;
|
||||||
final RecordingHealthStatus healthStatus;
|
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 tripDistanceMiles;
|
||||||
final double topSpeedMph;
|
final double topSpeedMph;
|
||||||
final double maxLeanLeft;
|
final double maxLeanLeft;
|
||||||
|
|
@ -41,6 +50,7 @@ class RideSession {
|
||||||
this.dbId,
|
this.dbId,
|
||||||
this.state = RideState.idle,
|
this.state = RideState.idle,
|
||||||
this.healthStatus = RecordingHealthStatus.recordingArmed,
|
this.healthStatus = RecordingHealthStatus.recordingArmed,
|
||||||
|
this.health = const RecordingHealthContract(),
|
||||||
this.tripDistanceMiles = 0.0,
|
this.tripDistanceMiles = 0.0,
|
||||||
this.topSpeedMph = 0.0,
|
this.topSpeedMph = 0.0,
|
||||||
this.maxLeanLeft = 0.0,
|
this.maxLeanLeft = 0.0,
|
||||||
|
|
@ -61,6 +71,7 @@ class RideSession {
|
||||||
int? dbId,
|
int? dbId,
|
||||||
RideState? state,
|
RideState? state,
|
||||||
RecordingHealthStatus? healthStatus,
|
RecordingHealthStatus? healthStatus,
|
||||||
|
RecordingHealthContract? health,
|
||||||
double? tripDistanceMiles,
|
double? tripDistanceMiles,
|
||||||
double? topSpeedMph,
|
double? topSpeedMph,
|
||||||
double? maxLeanLeft,
|
double? maxLeanLeft,
|
||||||
|
|
@ -80,6 +91,7 @@ class RideSession {
|
||||||
dbId: dbId ?? this.dbId,
|
dbId: dbId ?? this.dbId,
|
||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
healthStatus: healthStatus ?? this.healthStatus,
|
healthStatus: healthStatus ?? this.healthStatus,
|
||||||
|
health: health ?? this.health,
|
||||||
tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles,
|
tripDistanceMiles: tripDistanceMiles ?? this.tripDistanceMiles,
|
||||||
topSpeedMph: topSpeedMph ?? this.topSpeedMph,
|
topSpeedMph: topSpeedMph ?? this.topSpeedMph,
|
||||||
maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft,
|
maxLeanLeft: maxLeanLeft ?? this.maxLeanLeft,
|
||||||
|
|
@ -182,16 +194,146 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
_recoverInterruptedRides();
|
_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() {
|
void _checkWatchdog() {
|
||||||
if (state.state != RideState.riding) return;
|
if (state.state != RideState.riding) return;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (_lastTelemetryReceivedTime != null) {
|
// Existing canonical stall detection: the 4-second bound and the telemetryGap
|
||||||
final elapsedSec = now.difference(_lastTelemetryReceivedTime!).inSeconds;
|
// diagnostic are unchanged. RID-3C-1 adds NO second watchdog; it makes this
|
||||||
if (elapsedSec > 4) {
|
// one the source of heartbeat and consecutive-miss evidence for the contract.
|
||||||
// Telemetry pipeline has stalled
|
final elapsedSec = _lastTelemetryReceivedTime == null
|
||||||
if (state.healthStatus != RecordingHealthStatus.recordingDegraded) {
|
? null
|
||||||
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingDegraded);
|
: 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 {
|
try {
|
||||||
ref.read(fieldTestLoggerProvider).log(
|
ref.read(fieldTestLoggerProvider).log(
|
||||||
eventType: FieldTestEventType.telemetryGap,
|
eventType: FieldTestEventType.telemetryGap,
|
||||||
|
|
@ -200,8 +342,17 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} 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 {
|
Future<void> _recoverInterruptedRides() async {
|
||||||
|
|
@ -292,10 +443,16 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
motorcycleVariant: defaultBike?.variantName,
|
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(
|
state = state.copyWith(
|
||||||
dbId: id,
|
dbId: id,
|
||||||
state: RideState.riding,
|
state: RideState.riding,
|
||||||
healthStatus: RecordingHealthStatus.recordingActive,
|
healthStatus: RecordingHealthStatus.recordingArmed,
|
||||||
|
health: const RecordingHealthContract(),
|
||||||
startTime: startTime,
|
startTime: startTime,
|
||||||
tripDistanceMiles: 0,
|
tripDistanceMiles: 0,
|
||||||
topSpeedMph: 0,
|
topSpeedMph: 0,
|
||||||
|
|
@ -317,6 +474,12 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
_stoppedTimeAccumulator = 0.0;
|
_stoppedTimeAccumulator = 0.0;
|
||||||
_lastTelemetryReceivedTime = DateTime.now();
|
_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 {
|
try {
|
||||||
WakelockPlus.enable().catchError((_) {});
|
WakelockPlus.enable().catchError((_) {});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -362,9 +525,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumeRide() {
|
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
|
_lastSample = null; // reset to avoid jumping
|
||||||
_lastTelemetryReceivedTime = DateTime.now();
|
_lastTelemetryReceivedTime = DateTime.now();
|
||||||
|
// RID-3C-1: re-publish truthful health for the resumed session.
|
||||||
|
_evaluateRecordingHealth();
|
||||||
try {
|
try {
|
||||||
WakelockPlus.enable().catchError((_) {});
|
WakelockPlus.enable().catchError((_) {});
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -526,12 +694,18 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
// every subsequent ride continue to record normally.
|
// every subsequent ride continue to record normally.
|
||||||
if (_telemetryAcceptanceClosed) return;
|
if (_telemetryAcceptanceClosed) return;
|
||||||
|
|
||||||
// RID-3B-1: incoming telemetry must not clobber recordingFinalizing back to
|
// RID-3C-1: an accepted sample is EVIDENCE, not a health verdict. Record only
|
||||||
// recordingActive while finalization is in progress. Minimum private
|
// what it actually proves - a real GPS fix timestamp, a real IMU sample
|
||||||
// condition: the existing RID-2 in-flight finish flag.
|
// timestamp, monotonic timing - and let the contract decide the status.
|
||||||
if (!_isFinishing &&
|
//
|
||||||
state.healthStatus != RecordingHealthStatus.recordingActive) {
|
// RID-3B-1 is preserved twice over: this runs AFTER the acceptance cutoff gate
|
||||||
state = state.copyWith(healthStatus: RecordingHealthStatus.recordingActive);
|
// 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);
|
_queueForBatch(sample);
|
||||||
|
|
@ -699,6 +873,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
// arrived during the await, so a later legitimate flush can retry them.
|
// arrived during the await, so a later legitimate flush can retry them.
|
||||||
// No automatic retry worker is added.
|
// No automatic retry worker is added.
|
||||||
_telemetryBatch.insertAll(0, batchToInsert);
|
_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
|
// 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
|
// begun disposal must not touch ref, so the diagnostic is skipped in that
|
||||||
// case; it remains bounded either way.
|
// 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.
|
// consistent, and no database counter or schema field is added.
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
try {
|
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 (_) {
|
} catch (_) {
|
||||||
// Post-commit bookkeeping failure must never be classified as a
|
// Post-commit bookkeeping failure must never be classified as a
|
||||||
// persistence failure and must never restore the committed snapshot.
|
// persistence failure and must never restore the committed snapshot.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,21 @@ enum RecordingHealthStatus {
|
||||||
/// Strict Recording Health Contract state tracker (Phase 5)
|
/// Strict Recording Health Contract state tracker (Phase 5)
|
||||||
class RecordingHealthContract {
|
class RecordingHealthContract {
|
||||||
final RecordingHealthStatus status;
|
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 isForegroundServiceAlive;
|
||||||
final bool isTelemetryPipelineActive;
|
final bool isTelemetryPipelineActive;
|
||||||
final bool isAtLeastOneSensorActive;
|
final bool isAtLeastOneSensorActive;
|
||||||
|
|
@ -111,4 +126,92 @@ class RecordingHealthContract {
|
||||||
isAtLeastOneSensorActive &&
|
isAtLeastOneSensorActive &&
|
||||||
isDatabasePersistenceOperational &&
|
isDatabasePersistenceOperational &&
|
||||||
isValidSessionId;
|
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),
|
intervalDuration: const Duration(milliseconds: 100),
|
||||||
forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness
|
forceLocationManager: false, // Use Google Fused Location Provider for best accuracy & responsiveness
|
||||||
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
foregroundNotificationConfig: const ForegroundNotificationConfig(
|
||||||
notificationText: 'MotorSignal is recording ride telemetry',
|
// RID-3C-1: permanently truthful wording.
|
||||||
notificationTitle: 'MotorSignal Active Ride',
|
//
|
||||||
|
// 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,
|
enableWakeLock: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ void main() {
|
||||||
await db.close();
|
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(
|
final container = ProviderContainer(
|
||||||
overrides: [
|
overrides: [
|
||||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
|
|
@ -141,7 +141,13 @@ void main() {
|
||||||
session = container.read(rideStateMachineProvider);
|
session = container.read(rideStateMachineProvider);
|
||||||
expect(session.state, equals(RideState.riding));
|
expect(session.state, equals(RideState.riding));
|
||||||
expect(session.dbId, isNotNull);
|
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
|
// Emit 10 telemetry samples
|
||||||
for (int i = 0; i < 10; i++) {
|
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