Merge pull request #4 from bponders12-cell/fix/gnss-freshness-semantics

fix(telemetry): harden GNSS freshness semantics
This commit is contained in:
Billie Ponder 2026-09-19 13:50:14 -04:00 committed by GitHub
commit 2a8c438f07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2560 additions and 37 deletions

106
docs/CODE_MAP.md Normal file
View File

@ -0,0 +1,106 @@
# MOTORSIGNAL DEVELOPER OWNERSHIP MAP — PHASE 1
## A. Source State
- Known Baseline Branch: `master`
- Checkpoint Commit: `d3caf37bb2ff0031bec5982791b77127dd341cbd` (Merge pull request #3)
- No tracked or staged modifications exist in production files.
## B. Data Flow (Sensor to Pixels)
1. **Acquisition (`lib/services/location_service.dart`)**
- `Geolocator.getPositionStream()` initializes the Android Fused Location Provider.
- Settings: `LocationAccuracy.bestForNavigation` at a requested 100ms interval.
- Triggers `_handleRawPosition()` upon receiving an OS-level GPS fix.
2. **Validation (`LocationService._isValidPosition`)**
- Validates the fix before applying logic.
- Drops fixes with > 200m accuracy (bootstrapping) or > 80m-100m accuracy (moving).
- Drops impossible speeds (> 100.0 m/s or ~223 MPH).
- Drops impossible acceleration (> 30.0 m/s²) and detects GPS teleportation over 50 meters.
3. **Filtering & Smoothing (`LocationService._applyEmaFilter`)**
- **Stationary Jitter Deadband**: Speeds under 0.6 m/s (~1.3 MPH) are forced to 0.0 to prevent drifting at stoplights.
- **Fast Dynamic Exponential Moving Average (EMA)**:
- `alpha = 0.90` when delta > 0.4 m/s (preserves instant responsiveness when accelerating/braking).
- `alpha = 0.75` for cruising.
- The smoothed speed (in m/s) is added to `_controller.stream`.
4. **Sampling & The Primary Clock (`lib/services/telemetry_service.dart`)**
- Subscribes to the location stream but **decouples** acquisition from reporting.
- Uses a `Timer.periodic(const Duration(milliseconds: 50))` to establish a requested 20Hz monotonic clock.
- `_emitSample()` reads the latest filtered position, converts speed from m/s to MPH (`speed * 2.23694`), and emits a `TelemetrySample`.
5. **State Aggregation (`lib/features/rides/ride_state_machine.dart`)**
- Consumes `TelemetrySample`s at 20Hz via `_onTelemetryUpdate`.
- Modifies dynamic metric tracking (`_processStats`) and performs auto-starts (gate: `>= 1.5 MPH`).
- Isolates motorcycle dynamics (Top Speed, Lean Angle, G-Force) behind a `>= 3.0 MPH` gate to prevent kickstand or walking artifacts from registering as personal records.
6. **UI Display (`lib/features/dashboard/widgets/speed_readout.dart`)**
- Rebuilding the entire dashboard at 20Hz would cause severe UI lag.
- `ResponsiveDashboardLayout` isolates updates using the `SpeedReadout` leaf widget.
- `SpeedReadout` selects freshness-aware `sample.freshSpeed` from `telemetryStreamProvider`; unavailable/stale speed renders as `--`.
- Clamps the value to `>= 0.0`, converts to an integer string (`toStringAsFixed(0)`), and renders.
## C. Discoveries (Current implementation speeds)
MotorSignal manages speed in several distinct concepts:
- **Raw GNSS Speed**: `position.speed` in `LocationService` (m/s directly from OS, unfiltered).
- **Filtered Speed**: `_emaSpeed` in `LocationService` (m/s after EMA and deadband).
- **Telemetry Speed**: `sample.speed` in `TelemetrySample` (converted to MPH, sampled at 20Hz).
- **Display Speed**: Clamped to `>= 0.0` inside `SpeedReadout` before integer rounding.
- **Top Speed (Current implementation)**: `state.topSpeedMph` managed by `RideStateMachine`, rejecting single spikes > 220 MPH.
- **Average Speed**: Derived in `RideStateMachine` (`(tripDistanceMiles / newMovingTime) * 3600`) — relies heavily on the `1.8 MPH` threshold determining `movingTime`.
## D. Boundaries (High-Risk Concepts)
The following behaviors are load-bearing and architecturally high-risk:
1. **The 20Hz / 50ms Clock**: `TelemetryService._samplingTimer` is the primary telemetry sampling clock. The UI does not poll for location or dictate sampling frequency.
2. **Stationary Deadband (< 0.6 m/s)**: Enforced in `LocationService` to prevent the speedometer from bouncing while idling.
3. **Dynamic Metrics Gate (< 3.0 MPH)**: `RideStateMachine` ignores lean angles and heavy G-forces when rolling slowly.
4. **Auto-Start Gate (>= 1.5 MPH)**: `RideStateMachine` uses this threshold to transition from `ready` to active recording.
5. **GPS Freshness Invariant**: GPS freshness advances from genuinely newer underlying GNSS evidence, not merely from creation of a new telemetry wrapper. Repeated or cached GNSS fixes repackaged into 20 Hz `TelemetrySample`s do not refresh `_lastGpsEvidenceMonotonicMs`.
6. **Speed Freshness Invariant**: Fresh telemetry wrapper does not imply fresh GNSS-derived speed. Speed freshness (`TelemetrySample.isSpeedFresh` / `TelemetrySample.freshSpeed`) is valid only while the underlying GNSS fix age is within the canonical 4000 ms staleness bound. Stale GNSS fixes render as `'--'` on `SpeedReadout` rather than freezing on the last measured speed or fabricating 0 MPH.
7. **GNSS-Derived Metrics Integrity Invariant**: Stale GNSS evidence may remain preserved in raw telemetry (`sample.speed`), but it must not create new GNSS-derived ride facts:
- **Top Speed**: `state.topSpeedMph` updates only when `sample.isSpeedFresh && sample.speed != null && sample.speed! <= 220.0`.
- **Moving Time & Stopped Time**: Sub-second continuous time accumulation (`_movingTimeAccumulator` / `_stoppedTimeAccumulator`) requires `sample.isSpeedFresh && _lastSample!.isSpeedFresh`. Stale samples do not accumulate moving or stopped time.
- **Dynamic Motorcycle Metrics Gate**: Dynamic metrics (max lean, max accel/braking G) require `sample.isSpeedFresh && sample.speed != null && sample.speed! >= 3.0`, preventing stationary/kickstand artifacts during a GPS outage from polluting records.
- **Trip Distance & Elevation**: Requires `hasFreshGps = sample.isSpeedFresh && (_lastSample?.isSpeedFresh ?? false)`. Distance and elevation do not accumulate across an outage, preventing artificial catch-up jumps upon recovery.
- **High Speed Event Detection**: Logging 'High Speed' requires `sample.isSpeedFresh && sample.speed != null && sample.speed! > 100.0`, preventing repeated events during an outage.
- **Performance Launch State Machine**: Active runs (`launched` or `countdown`) abort with `PerformanceAbortReason.gpsLost` when GPS becomes stale. `ready` state reverts to `idle` on stale GPS, preventing false movement detection or high-speed launch anomalies upon recovery.
- **Performance Timer Service**: Active runs in `timing` state abort when GPS becomes stale, preventing false 0-60/0-100 completions across outages.
8. **Heading & Location Freshness Invariant**:
- **Heading Selection**: `TelemetryService._emitSample()` selects GPS course (`_latestPosition.heading`) only when GPS is fresh (`isGpsFresh`), `_latestPosition.heading >= 0`, and `speedMph > 3.0`. When GPS becomes stale (> 4000 ms), heading selection immediately falls back to live hardware compass azimuth (`_latestCompassHeading`), preventing stale course angles from overriding the rider's actual orientation. When fresh GPS resumes and speed exceeds 3.0 MPH, GPS heading automatically recovers. Normal 10 Hz / 20 Hz sample repetitions within 4000 ms maintain heading stability without flicker.
- **Location & Map Presentation**: `DashboardMapPanel` consumes `s.value?.isGpsFresh`. When GPS coordinates are fresh, the vehicle marker renders in active CyberCyan without warning overlays. When GPS becomes stale (> 4000 ms), the last-known coordinate is retained without artificial dead-reckoning or fake drift, the vehicle marker is dimmed (alpha 0.38), and a subtle `LAST KNOWN LOCATION` pill badge is displayed. When fresh GPS recovers, the badge disappears and the marker restores full CyberCyan.
- **GPS Status Indicator**: `GPSStatusIndicator` evaluates `isGpsFresh`. When GPS is stale, it truthfully indicates `NO GPS` with `Icons.gps_off` rather than falsely reporting `GPS 3D FIX` from cached accuracy.
## E. Tests
- `test/location_service_test.dart`:
- Protects physical/GPS thresholds (0.6 m/s deadband == 1.34 MPH, 100 m/s limit == 223 MPH, 3.0s 0-60 == 0.91g).
- `test/telemetry_validation_test.dart`:
- Validates `TelemetryValidator`'s sanity checks, such as rejecting > 220 MPH jumps and flagging poor GPS confidence.
- `test/stale_gps_regression_test.dart`:
- Validates GPS evidence freshness semantics: duplicate fix timestamps do not refresh GPS freshness, genuinely newer fixes refresh evidence, older out-of-order fixes are ignored, null fixes do not refresh evidence, normal 10 Hz / 20 Hz sampling remains healthy, GPS outages degrade health to `recordingDegraded`, and fresh fixes recover to `recordingActive`.
- `test/truthful_speed_presentation_test.dart`:
- Validates speed freshness semantics on `TelemetrySample` and `SpeedReadout` presentation across normal moving GPS, 10 Hz / 20 Hz duplication, GNSS outages, non-zero placeholder behavior (`'--'`), genuine stopped motorcycle (`0` MPH), automatic recovery, and ride-independent operation.
- `test/gnss_metrics_integrity_test.dart`:
- Validates GNSS-derived metrics integrity during stale GPS: moving time cessation, distance cessation, top speed isolation, boundary conditions at 3999ms / 4000ms / 4001ms, recovery without jump distance or time integration across outage, dynamic motorcycle metrics isolation, High Speed event deduplication, PerformanceLaunchStateMachine abort on stale GPS in launched/countdown and reversion in ready, and PerformanceTimerService abort in timing.
- `test/truthful_heading_location_test.dart`:
- Validates heading selection and map/location presentation truthfulness during stale GPS: GPS course priority when moving fresh, normal 10 Hz / 20 Hz fix stability, fallback to live compass when GPS is stale (> 4000ms), automatic heading recovery on fresh GPS, 3999/4000/4001ms boundaries, map marker dimming, LAST KNOWN LOCATION pill badge, absence of fake drift / dead reckoning, automatic map recovery, ride independence, and raw forensic data preservation.
## F. Documents Created
- `docs/CODE_MAP.md`
- `docs/DEBUGGING_MAP.md`
## G. Change Scope
- `lib/features/rides/ride_state_machine.dart`: Added `_lastSeenGpsTimestamp` tracking so GPS evidence freshness advances only on genuinely newer GNSS fixes. Aligned lifecycle reset in `_resetHealthEvidence()` and `reset()`. Gated `newTopSpeed`, `isDynamicallyMoving`, continuous moving/stopped time accumulation, distance accumulation, elevation accumulation, and 'High Speed' event detection on `sample.isSpeedFresh`.
- `lib/models/telemetry_sample.dart`: Added `isSpeedFresh`, `freshSpeed`, and `isGpsFresh` getters reusing `RecordingHealthContract.evidenceStaleAfterMs` (4000 ms).
- `lib/features/dashboard/widgets/speed_readout.dart`: Updated `SpeedReadout` to present `freshSpeed` and render `'--'` when stale.
- `lib/features/performance/services/performance_launch_state_machine.dart`: Added stale GPS check in `_onTelemetryUpdate` to abort active runs (`launched`, `countdown`) with `PerformanceAbortReason.gpsLost` and revert `ready` to `idle`.
- `lib/features/performance/performance_timer_service.dart`: Added stale GPS check in `_onTelemetryUpdate` to abort `timing` state on stale GPS.
- `lib/services/telemetry_service.dart`: Gated GPS heading selection on `isGpsFresh` using `RecordingHealthContract.evidenceStaleAfterMs` (4000 ms).
- `lib/features/dashboard/widgets/dashboard_map_panel.dart`: Dimmed vehicle marker and added `LAST KNOWN LOCATION` pill badge when `!isGpsFresh`.
- `lib/features/dashboard/widgets/gps_status_indicator.dart`: Required `isGpsFresh` for reporting active GPS fix status.
- `test/stale_gps_regression_test.dart`: Added comprehensive behavioral test suite for stale GPS, duplicate fixes, outage degradation, and recovery.
- `test/truthful_speed_presentation_test.dart`: Added comprehensive behavioral and widget test suite for truthful speed presentation.
- `test/gnss_metrics_integrity_test.dart`: Added comprehensive behavioral test suite for GNSS-derived metrics integrity during stale GPS.
- `test/truthful_heading_location_test.dart`: Added comprehensive behavioral and widget test suite for truthful heading selection and location presentation.
## H. Remaining Issues / Future Considerations
- All primary GNSS-derived subsystems (recording health, speedometer presentation, ride statistics, performance launch timers, heading selection, map/location presentation, and GPS status indicator) now operate under a single, unified canonical freshness contract (4000 ms bound). No further unaddressed stale GNSS corruption vectors remain in core telemetry paths.

79
docs/DEBUGGING_MAP.md Normal file
View File

@ -0,0 +1,79 @@
# MOTORSIGNAL DEBUGGING MAP
This document maps common symptoms in the MotorSignal app to their specific root causes in the GNSS/Telemetry/Speedometer subsystems. Use this guide to jump directly to the responsible layer during triage.
## 1. Symptom: "Speedometer shows 0 while moving"
**Possible Causes & Investigation:**
- **Permissions Issue**: If OS-level location permissions were denied or revoked, `LocationService` stream does not initialize.
- *Check*: `LocationService.hasPermissions()`
- **Deadband Overreach**: If the rider is moving slower than 1.3 MPH (0.6 m/s), `LocationService._applyEmaFilter` enforces 0.0 to prevent stationary jitter.
- *Check*: `LocationService._applyEmaFilter`
- **Validation Rejection**: The OS is sending fixes with terrible accuracy (> 80m-100m) or physically impossible acceleration, causing `LocationService._isValidPosition` to silently drop the fix instead of updating the EMA.
- *Check*: Look for `Accuracy poor` or `Impossible acceleration` logs from `_emitGpsDiagnostic`.
## 2. Symptom: "Speedometer freezes at a high speed during a tunnel/dead zone"
**Possible Causes & Investigation:**
- **Signal Loss Freeze**: When GPS fixes stop arriving, `LocationService` stops emitting new positions. However, `TelemetryService._emitSample()` continues to fire at 20Hz using `_latestPosition` (which contains the last known high speed).
- *Status (RESOLVED)*:
1. **Recording Health (Phase 1D)**: `RideStateMachine._recordTelemetryEvidence()` checks `_lastSeenGpsTimestamp` and only refreshes GPS freshness when a genuinely newer GPS fix arrives. After `evidenceStaleAfterMs` (4000 ms) without a new fix, `RideStateMachine` truthfully transitions recording health to `DEGRADED`.
2. **Speed Presentation (Phase 1E)**: `TelemetrySample` now exposes `isSpeedFresh` and `freshSpeed`, evaluating the GNSS fix age against the canonical 4000 ms staleness bound. `SpeedReadout` consumes `freshSpeed` and truthfully presents `--` when GPS is stale or unavailable, preventing frozen speeds from being displayed while avoiding fabricated zero-speed measurements. When fresh GPS fixes resume, numerical speed presentation automatically recovers.
## 3. Symptom: "Speedometer lags significantly behind real-world braking/acceleration"
**Possible Causes & Investigation:**
- **EMA Tuning Conflict**: `LocationService._applyEmaFilter` uses a fast dynamic alpha (0.90) for accelerating/braking and 0.75 for cruising. If the delta threshold (> 0.4 m/s) isn't being tripped cleanly due to noisy GPS, it may stick to the slower 0.75 cruising alpha.
- *Check*: `LocationService._applyEmaFilter`
## 4. Symptom: "Leaning the bike on the kickstand records a new 'Max Lean Angle' record"
**Possible Causes & Investigation:**
- **Low-Speed Gate Failure**: `RideStateMachine._processStats` restricts dynamic metrics (lean, accel, braking) to speeds `>= 3.0 MPH`. If a false GPS speed spike momentarily registers > 3.0 MPH while the bike is tilted on the kickstand, it can permanently update `state.maxLeanLeft` or `state.maxLeanRight`.
- *Check*: `RideStateMachine._processStats` (`isDynamicallyMoving = sample.speed! >= 3.0`).
## 5. Symptom: "App constantly auto-starts a ride while parked in the driveway"
**Possible Causes & Investigation:**
- **Auto-Start Jitter**: `RideStateMachine._onTelemetryUpdate` promotes a `ready` state to `riding` if `sample.speed! >= 1.5`. If `LocationService` fails to enforce the 0.6 m/s (1.3 MPH) deadband correctly, GPS multipath bounce in a driveway can trigger a false start.
- *Check*: `LocationService._applyEmaFilter` deadband logic.
## 6. Symptom: "Dashboard flickers or UI lags severely during high-speed rides"
**Possible Causes & Investigation:**
- **Provider Granularity Issue**: The dashboard is supposed to isolate high-frequency (20Hz) UI updates entirely within `SpeedReadout` (which uses `.select` on the telemetry provider). If an outer widget (like `ResponsiveDashboardLayout` or `DashboardScreen`) accidentally watches the root `telemetryStreamProvider` without `.select`, it will trigger 20 rebuilds per second of the entire screen.
- *Check*: `EvolutionPortraitDashboard` and `ResponsiveDashboardLayout` usage of `ref.watch`.
## 7. Symptom: "GPS Weak / Degraded indicator won't go away even with clear skies"
**Possible Causes & Investigation:**
- **Pre-commit DB Failure or Watchdog Staleness**: The dashboard claims `RECORDING` or `DEGRADED` based entirely on the primary `RecordingHealthContract` in `RideStateMachine`. If `_lastDbCommitMonotonicMs` stops advancing (due to a SQLite deadlock) or `_lastAcceptedTelemetryTime` stops advancing, health defaults to degraded.
- *Check*: Database flush interval in `RideStateMachine._persistOwnedTelemetrySnapshot` and the `_evaluateRecordingHealth` watchdog loop.
## 8. Symptom: "Trip distance or moving time inflated after riding through tunnel/dead zone"
**Possible Causes & Investigation:**
- **Stale GNSS Integration (RESOLVED in Phase 1F)**: When GPS fixes stop advancing, `TelemetryService` emits cached positions at 20Hz. Previously, `_movingTimeAccumulator` continued to increment based on the cached speed, and upon GPS recovery, the distance between pre-outage coordinates and post-outage coordinates could be added as an artificial catch-up jump.
- *Resolution*: `RideStateMachine._processStats` gates moving/stopped time on `sample.isSpeedFresh && _lastSample!.isSpeedFresh`. Distance and elevation require `hasFreshGps = sample.isSpeedFresh && (_lastSample?.isSpeedFresh ?? false)`. When GPS is lost, time and distance cease accumulating; upon recovery, the first fresh fix resets baseline coordinates without integrating across the outage gap.
- *Check*: `RideStateMachine._processStats` time accumulation, distance, and elevation gating.
## 9. Symptom: "Performance launch timer records anomalous 0-60 time or finishes without GPS"
**Possible Causes & Investigation:**
- **Stale Speed in Performance State Machine (RESOLVED in Phase 1F)**: If GPS fixes stop during an active launch or countdown, the state machine previously retained cached speeds, allowing runs to complete across signal loss or trigger high-speed launch anomalies upon recovery.
- *Resolution*: `PerformanceLaunchStateMachine` and `PerformanceTimerService` inspect `sample.isSpeedFresh`. In `launched` or `countdown`, active runs abort immediately with `PerformanceAbortReason.gpsLost`. In `ready`, the state machine reverts to `idle`, preventing high-speed launch anomalies when fresh GPS resumes.
- *Check*: `PerformanceLaunchStateMachine._onTelemetryUpdate` and `PerformanceTimerService._onTelemetryUpdate`.
## 10. Symptom: "Compass/Heading indicator remains stuck pointing in old direction after turning into tunnel/garage"
**Possible Causes & Investigation:**
- **Stale GNSS Heading Override (RESOLVED in Phase 1G)**: When GPS fixes stopped advancing while moving, `TelemetryService` continued to use the cached `_latestPosition.heading`, overriding live compass heading indefinitely.
- *Resolution*: `TelemetryService._emitSample()` gates GPS heading selection on `isGpsFresh` (4000 ms bound). When GPS fix age exceeds 4000 ms, heading immediately falls back to live hardware compass.
- *Check*: `TelemetryService._emitSample()` heading selection and `CompassService.headingStream`.
## 11. Symptom: "Map marker shows vehicle at active location when GPS signal is lost"
**Possible Causes & Investigation:**
- **Unindicated Stale Map Coordinate (RESOLVED in Phase 1G)**: `DashboardMapPanel` rendered the vehicle marker identically whether coordinates were current or stale.
- *Resolution*: `DashboardMapPanel` watches `s.value?.isGpsFresh`. When stale, the marker is dimmed and a subtle `LAST KNOWN LOCATION` badge is displayed, preserving the last-known position truthfully without fabricating position.
- *Check*: `DashboardMapPanel` and `TelemetrySample.isGpsFresh`.

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_service.dart';
import '../../../services/map/map_provider_interface.dart';
@ -27,6 +28,7 @@ class DashboardMapPanel extends ConsumerWidget {
lat: s.value?.latitude,
lng: s.value?.longitude,
heading: s.value?.heading ?? 0.0,
isGpsFresh: s.value?.isGpsFresh ?? false,
),
),
);
@ -38,13 +40,18 @@ class DashboardMapPanel extends ConsumerWidget {
? vehicleCoords.lng!
: -122.4194;
final isFresh = vehicleCoords.isGpsFresh;
final hasCoordinates = vehicleCoords.lat != null && vehicleCoords.lat != 0.0;
final vehicleMarker = AppMapMarker(
id: 'dash_vehicle',
position: AppLatLng(centerLat, centerLng),
heading: vehicleCoords.heading,
iconWidget: const Icon(
iconWidget: Icon(
Icons.navigation,
color: AppColors.cyberCyan,
color: isFresh
? AppColors.cyberCyan
: AppColors.cyberCyan.withValues(alpha: 0.38),
size: 36,
),
);
@ -89,11 +96,48 @@ class DashboardMapPanel extends ConsumerWidget {
return SizedBox(
width: constraints.maxWidth,
height: constraints.maxHeight,
child: UnifiedMapView(
key: ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng),
zoom: zoom,
markers: mapMarkers,
child: Stack(
children: [
Positioned.fill(
child: UnifiedMapView(
key: const ValueKey('dash_map_x'),
center: AppLatLng(centerLat, centerLng),
zoom: zoom,
markers: mapMarkers,
),
),
if (!isFresh && hasCoordinates)
Positioned(
top: 10,
left: 10,
child: Container(
key: const ValueKey('stale_gps_pill'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.warningAmber, width: 1.0),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.location_off, size: 13, color: AppColors.warningAmber),
SizedBox(width: 5),
Text(
'LAST KNOWN LOCATION',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: AppColors.warningAmber,
),
),
],
),
),
),
],
),
);
},

View File

@ -18,8 +18,10 @@ class GPSStatusIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final gpsAcc = ref.watch(
telemetryStreamProvider.select((s) => s.value?.gpsAccuracy),
final (gpsAcc, isFresh) = ref.watch(
telemetryStreamProvider.select(
(s) => (s.value?.gpsAccuracy, s.value?.isGpsFresh ?? false),
),
);
final theme = Theme.of(context);
@ -28,7 +30,7 @@ class GPSStatusIndicator extends ConsumerWidget {
final Color color;
final String label;
if (gpsAcc != null) {
if (gpsAcc != null && isFresh) {
if (gpsAcc <= 15.0) {
icon = Icons.gps_fixed;
color = AppColors.telemetryGreen;

View File

@ -13,12 +13,14 @@ class SpeedTelemetryObservation {
final double validatedSpeedMph;
final double filteredSpeedMph;
final double displayedSpeedMph;
final bool isFresh;
const SpeedTelemetryObservation({
required this.rawGnssSpeedMph,
required this.validatedSpeedMph,
required this.filteredSpeedMph,
required this.displayedSpeedMph,
this.isFresh = true,
});
}
@ -46,24 +48,54 @@ class SpeedReadout extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final speed = ref.watch(
telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0),
final speedState = ref.watch(
telemetryStreamProvider.select((s) {
final sample = s.value;
if (sample == null) {
// Pre-telemetry initial state (before first tick)
return (speed: 0.0, isFresh: true, isInitial: true);
}
return (
speed: sample.freshSpeed,
isFresh: sample.isSpeedFresh,
isInitial: false,
);
}),
);
final clampedSpeed = speed < 0.0 ? 0.0 : speed;
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseUnitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
if (onObservation != null) {
onObservation!(SpeedTelemetryObservation(
rawGnssSpeedMph: clampedSpeed,
validatedSpeedMph: clampedSpeed,
filteredSpeedMph: clampedSpeed,
displayedSpeedMph: clampedSpeed,
));
final String speedText;
final Color speedColor;
final Color unitColor;
if (speedState.isInitial) {
speedText = '0';
speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
unitColor = baseUnitColor;
} else if (!speedState.isFresh) {
// Stale or unavailable GNSS fix: display '--' to reflect truthful uncertainty
speedText = '--';
speedColor = isDark ? Colors.white38 : AppColors.lightOnSurface.withValues(alpha: 0.38);
unitColor = baseUnitColor.withValues(alpha: 0.5);
} else {
final clampedSpeed = (speedState.speed ?? 0.0) < 0.0 ? 0.0 : (speedState.speed ?? 0.0);
speedText = clampedSpeed.toStringAsFixed(0);
speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
unitColor = baseUnitColor;
}
final isDark = Theme.of(context).brightness == Brightness.dark;
final speedColor = isDark ? Colors.white : AppColors.lightOnSurface;
final unitColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
if (onObservation != null) {
final currentMph = speedState.speed ?? 0.0;
onObservation!(SpeedTelemetryObservation(
rawGnssSpeedMph: currentMph,
validatedSpeedMph: currentMph,
filteredSpeedMph: currentMph,
displayedSpeedMph: currentMph,
isFresh: speedState.isFresh,
));
}
final (double fontSize, double unitSize) = switch (size) {
SpeedReadoutSize.compact => (54.0, 14.0),
@ -79,7 +111,7 @@ class SpeedReadout extends ConsumerWidget {
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
clampedSpeed.toStringAsFixed(0),
speedText,
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: fontSize,

View File

@ -77,9 +77,22 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
void _onTelemetryUpdate(TelemetrySample sample) {
if (sample.speed == null) return;
// Reject stale GPS samples from mutating performance runs or launching
if (!sample.isSpeedFresh) {
// A recovered moving fix cannot establish when the launch occurred.
_lastSample = null;
if (state.state == PerformanceState.ready) {
state = state.copyWith(state: PerformanceState.idle);
}
if (state.state == PerformanceState.timing) {
state = state.copyWith(state: PerformanceState.aborted);
}
return;
}
// Reject corrupted or impossible samples
if (sample.quality != null && sample.quality!.isRejected) return;
final speedMph = sample.speed!;
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
@ -87,7 +100,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
if (state.state != PerformanceState.ready) {
state = state.copyWith(state: PerformanceState.ready);
}
} else if (speedMph >= _speedThresholdMph) {
} else if (speedMph >= _speedThresholdMph &&
state.state == PerformanceState.ready) {
// Start timing immediately when motorcycle launches past 1.0 MPH
// Interpolate the exact start time between the last frame (stopped) and this frame
_startTime = _interpolateTime(

View File

@ -130,6 +130,22 @@ class PerformanceLaunchStateMachine extends StateNotifier<PerformanceLaunchAttem
return;
}
// 1b. Reject stale GPS samples from mutating performance runs or launching
if (!sample.isSpeedFresh) {
if (state.state == PerformanceLaunchState.launched ||
state.state == PerformanceLaunchState.countdown) {
state = state.copyWith(
state: PerformanceLaunchState.aborted,
abortReason: PerformanceAbortReason.gpsLost,
);
} else if (state.state == PerformanceLaunchState.ready) {
state = state.copyWith(
state: PerformanceLaunchState.idle,
);
}
return;
}
// 2. Monotonic sequence sanity check (prevent stale/out-of-order samples from corrupting timing)
final sampleMonotonicMs = sample.monotonicTimestampMs ?? clock.elapsedMilliseconds;
final sampleSeq = sample.sequenceNumber ?? 0;

View File

@ -225,6 +225,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
// ---------------------------------------------------------------------------
int? _latestObservedMonotonicMs;
int? _lastGpsEvidenceMonotonicMs;
DateTime? _lastSeenGpsTimestamp;
int? _lastImuEvidenceMonotonicMs;
int? _lastDbCommitMonotonicMs;
bool _dbPersistenceFailed = false;
@ -238,6 +239,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
void _resetHealthEvidence() {
_latestObservedMonotonicMs = null;
_lastGpsEvidenceMonotonicMs = null;
_lastSeenGpsTimestamp = null;
_lastImuEvidenceMonotonicMs = null;
_lastDbCommitMonotonicMs = null;
_dbPersistenceFailed = false;
@ -266,8 +268,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
if (latest == null || monotonicMs > latest) {
_latestObservedMonotonicMs = monotonicMs;
}
// Real GPS fix evidence: the fix timestamp is present.
if (sample.gpsTimestamp != null) _lastGpsEvidenceMonotonicMs = monotonicMs;
// Real GPS fix evidence: genuinely newer fix timestamp is present.
if (sample.gpsTimestamp != null) {
final gpsTime = sample.gpsTimestamp!;
if (_lastSeenGpsTimestamp == null || gpsTime.isAfter(_lastSeenGpsTimestamp!)) {
_lastSeenGpsTimestamp = gpsTime;
_lastGpsEvidenceMonotonicMs = monotonicMs;
}
}
// Real IMU sample evidence: the IMU timestamp is present.
if (sample.imuTimestamp != null) _lastImuEvidenceMonotonicMs = monotonicMs;
_lastAcceptedTelemetryTime = DateTime.now();
@ -725,6 +733,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
void reset() {
state = RideSession();
_resetHealthEvidence();
}
void _onTelemetryUpdate(TelemetrySample sample) {
@ -1119,14 +1128,17 @@ class RideStateMachine extends StateNotifier<RideSession> {
double newElevationGain = state.elevationGain;
double newElevationLoss = state.elevationLoss;
if (sample.speed != null && sample.speed! <= 220.0) {
if (sample.isSpeedFresh && sample.speed != null && sample.speed! <= 220.0) {
if (sample.speed! > state.topSpeedMph) {
newTopSpeed = sample.speed!;
}
}
// Continuous sub-second time accumulation across 20Hz frames
if (_lastSample != null && sample.speed != null) {
if (_lastSample != null &&
sample.speed != null &&
sample.isSpeedFresh &&
_lastSample!.isSpeedFresh) {
final dt =
sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds /
1000.0;
@ -1142,7 +1154,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
}
// Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records)
final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0;
final isDynamicallyMoving =
sample.isSpeedFresh && sample.speed != null && sample.speed! >= 3.0;
if (isDynamicallyMoving &&
sample.leanAngle != null &&
@ -1180,17 +1193,19 @@ class RideStateMachine extends StateNotifier<RideSession> {
);
// Accumulate distance ONLY if moving and GPS accuracy is reasonable (suppresses stationary jitter at stop lights)
final hasFreshGps =
sample.isSpeedFresh && (_lastSample?.isSpeedFresh ?? false);
final isMoving =
(sample.speed != null && sample.speed! >= 1.0) ||
(_lastSample!.speed != null && _lastSample!.speed! >= 1.0);
final hasGoodGps =
sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0;
if (isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
if (hasFreshGps && isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
_accumulatedDistance += distMeters;
}
// Elevation tracking
if (sample.altitude != null && _lastSample!.altitude != null) {
if (hasFreshGps && sample.altitude != null && _lastSample!.altitude != null) {
final elevationDelta = sample.altitude! - _lastSample!.altitude!;
// Threshold filter to prevent phantom gain (e.g. > 1 meter change)
if (elevationDelta.abs() > 1.0 && elevationDelta.abs() < 50.0) {
@ -1277,7 +1292,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
);
}
if (sample.speed != null) {
if (sample.isSpeedFresh && sample.speed != null) {
checkAndLogEvent('High Speed', sample.speed!, sample.speed! > 100.0);
}
}

View File

@ -1,3 +1,4 @@
import 'recording_health_status.dart';
import '../services/validation/telemetry_validator.dart';
class TelemetrySample {
@ -73,6 +74,72 @@ class TelemetrySample {
this.quality,
});
/// Whether the encapsulated speed is fresh according to the canonical
/// evidence staleness bound (4000 ms).
///
/// Returns false when:
/// - [speed] is null (no speed measurement available)
/// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms)
/// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp]
///
/// Returns true for:
/// - Genuinely advancing GPS fixes with valid speed
/// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound
/// - Fresh 0.0 MPH stopped fixes
/// - Synthetic test samples where GPS metadata was not explicitly attached
bool get isSpeedFresh {
if (speed == null) return false;
if (gpsAgeMs != null) {
return gpsAgeMs! >= 0 &&
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
}
if (gpsTimestamp != null) {
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
return age >= 0 &&
age <= RecordingHealthContract.evidenceStaleAfterMs;
}
// Synthetic test samples without GPS age/timestamp retain speed validity.
return true;
}
/// Whether the encapsulated GNSS evidence (coordinates, heading, accuracy) is
/// fresh according to the canonical evidence staleness bound (4000 ms).
///
/// Returns false when:
/// - [latitude] or [longitude] is absent, non-finite, or out of range
/// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms)
/// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp]
///
/// Returns true for:
/// - Genuinely advancing GPS fixes with valid coordinates
/// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound
/// - Synthetic test samples where GPS metadata was not explicitly attached
bool get isGpsFresh {
if (latitude == null || longitude == null ||
!latitude!.isFinite || !longitude!.isFinite ||
latitude!.abs() > 90 || longitude!.abs() > 180) {
return false;
}
if (gpsAgeMs != null) {
return gpsAgeMs! >= 0 &&
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
}
if (gpsTimestamp != null) {
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
return age >= 0 &&
age <= RecordingHealthContract.evidenceStaleAfterMs;
}
// Synthetic test samples without GPS age/timestamp retain validity.
return true;
}
/// Current trustworthy speed in MPH, or null if GPS evidence is absent or stale.
///
/// Prevents stale cached GNSS fixes from presenting old speeds indefinitely
/// while keeping genuine fresh 0.0 MPH measurements distinguishable from
/// unavailable/stale speed.
double? get freshSpeed => isSpeedFresh ? speed : null;
TelemetrySample copyWith({
DateTime? timestamp,
int? sequenceNumber,

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../models/telemetry_sample.dart';
import '../models/recording_health_status.dart';
import '../models/field_test_event.dart';
import 'location_service.dart';
import 'sensor_fusion_service.dart';
@ -121,10 +122,13 @@ class TelemetryService {
final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0;
final gpsAgeMs = _latestPosition != null ? now.difference(_latestPosition!.timestamp).inMilliseconds : null;
final isGpsFresh = gpsAgeMs != null &&
gpsAgeMs >= 0 &&
gpsAgeMs <= RecordingHealthContract.evidenceStaleAfterMs;
// Use GPS heading if moving, otherwise fallback to compass
// Use GPS heading if moving with fresh GPS evidence, otherwise fallback to compass
double finalHeading = _latestCompassHeading;
if (_latestPosition != null && _latestPosition!.heading >= 0 && speedMph > 3.0) {
if (isGpsFresh && _latestPosition != null && _latestPosition!.heading >= 0 && speedMph > 3.0) {
finalHeading = _latestPosition!.heading;
}

View File

@ -226,6 +226,8 @@ void main() {
leanAngle: 24.5,
longitudinalG: 0.42,
lateralG: 0.35,
latitude: 37.7749,
longitude: -122.4194,
gpsAccuracy: 9.0,
),
);

View File

@ -0,0 +1,712 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart';
import 'package:motorcycle_dash/features/performance/services/monotonic_clock.dart';
import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.dart';
import 'package:motorcycle_dash/features/performance/performance_timer_service.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:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart'
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
import 'package:drift/native.dart';
class MockAudioAlertService extends Fake implements AudioAlertService {
@override
Future<void> speak(String text, {bool force = false}) async {}
}
class FakeTelemetryService extends TelemetryService {
final StreamController<TelemetrySample> _controller =
StreamController<TelemetrySample>.broadcast();
FakeTelemetryService(
super.loc,
super.fusion,
super.compass,
super.logger,
super.validator,
);
@override
Stream<TelemetrySample> get telemetryStream => _controller.stream;
void emitSample(TelemetrySample sample) => _controller.add(sample);
@override
void dispose() => _controller.close();
}
class _NullLocationService extends Fake implements LocationService {
@override
void Function(Position position, bool accepted, String? rejectionReason)?
onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => const Stream.empty();
}
class _NullSensorFusionService extends Fake implements SensorFusionService {
@override
Stream<double> get leanAngleStream => const Stream.empty();
@override
Stream<double> get pitchAngleStream => const Stream.empty();
@override
Stream<GForceData> get gForceStream => const Stream.empty();
@override
double get latestRollRateDegPerSec => 0.0;
@override
double get latestRawAccelX => 0.0;
@override
double get latestRawAccelY => 0.0;
@override
double get latestRawAccelZ => 0.0;
@override
double get latestRawGyroX => 0.0;
@override
double get latestRawGyroY => 0.0;
@override
double get latestRawGyroZ => 0.0;
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FieldTestLogger logger;
late FakeTelemetryService fakeTelemetry;
late MockAudioAlertService mockAudio;
late TelemetryValidator validator;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeTelemetry = FakeTelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
validator,
);
mockAudio = MockAudioAlertService();
});
tearDown(() async {
fakeTelemetry.dispose();
logger.dispose();
await db.close();
});
ProviderContainer buildContainer() {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
return container;
}
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
TelemetrySample createSample({
required int monotonicMs,
DateTime? gpsTimestamp,
int? gpsAgeMs,
DateTime? imuTimestamp,
double speed = 35.0,
double lat = 34.0,
double lon = -85.0,
double? altitude,
double? leanAngle,
double? longitudinalG,
double? gpsAccuracy = 10.0,
}) {
return TelemetrySample(
timestamp: baseTime.add(Duration(milliseconds: monotonicMs)),
monotonicTimestampMs: monotonicMs,
latitude: lat,
longitude: lon,
altitude: altitude,
speed: speed,
gpsTimestamp: gpsTimestamp,
gpsAgeMs: gpsAgeMs,
imuTimestamp:
imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)),
leanAngle: leanAngle ?? 0.0,
longitudinalG: longitudinalG ?? 0.0,
gpsAccuracy: gpsAccuracy,
quality: const TelemetryQuality(
status: ValidationStatus.valid,
overallConfidence: 1.0,
),
);
}
group('Phase 1F — GNSS-Derived Metrics Integrity During Stale GPS', () {
test('1. Moving vehicle enters GPS outage: moving time stops accumulating after stale boundary', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
final gpsFixTime = baseTime;
// 100 samples at 20 Hz (5 seconds). First 4s are fresh, last 1s is stale (> 4000ms).
for (int i = 0; i <= 100; i++) {
final monoMs = i * 50;
final sampleTime = baseTime.add(Duration(milliseconds: monoMs));
final ageMs = sampleTime.difference(gpsFixTime).inMilliseconds;
fakeTelemetry.emitSample(createSample(
monotonicMs: monoMs,
gpsTimestamp: gpsFixTime,
gpsAgeMs: ageMs,
speed: 40.0,
));
}
await Future<void>.delayed(const Duration(milliseconds: 50));
final state = container.read(rideStateMachineProvider);
// Fresh duration was 4.0s (80 frames of 50ms). Stale frames after 4000ms should NOT accumulate.
expect(state.movingTimeSeconds, inInclusiveRange(3, 4));
// Moving time must NOT have reached 5 seconds
expect(state.movingTimeSeconds, lessThan(5));
});
test('2. Moving vehicle enters GPS outage: trip distance stops accumulating', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
final gpsFixTime = baseTime;
// First sample
fakeTelemetry.emitSample(createSample(
monotonicMs: 0,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 0,
speed: 40.0,
lat: 34.0000,
lon: -85.0000,
));
// Advance 1 second with advancing coordinates while fresh
for (int i = 1; i <= 20; i++) {
fakeTelemetry.emitSample(createSample(
monotonicMs: i * 50,
gpsTimestamp: gpsFixTime,
gpsAgeMs: i * 50,
speed: 40.0,
lat: 34.0000 + (i * 0.0001),
lon: -85.0000,
));
}
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceFresh = container.read(rideStateMachineProvider).tripDistanceMiles;
expect(distanceFresh, greaterThan(0.0));
// Now GPS fix stops advancing. We enter stale period (> 4000ms).
// Even if coordinates somehow changed in a stale sample, distance must NOT accumulate.
for (int i = 81; i <= 120; i++) {
fakeTelemetry.emitSample(createSample(
monotonicMs: i * 50,
gpsTimestamp: gpsFixTime,
gpsAgeMs: i * 50, // 4050ms to 6000ms -> STALE
speed: 40.0,
lat: 34.0000 + (i * 0.0001),
lon: -85.0000,
));
}
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceAfterStale = container.read(rideStateMachineProvider).tripDistanceMiles;
expect(distanceAfterStale, equals(distanceFresh));
});
test('3. Moving vehicle enters GPS outage: top speed does not update on stale samples', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
// Fresh sample at 50 MPH
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 0,
speed: 50.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0);
// Stale sample with higher speed (85 MPH) but stale GPS (> 4000ms)
fakeTelemetry.emitSample(createSample(
monotonicMs: 6000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 5000, // 5000ms > 4000ms -> STALE
speed: 85.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Top speed must remain 50.0 MPH, NOT 85.0 MPH
expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0);
});
test('4. Boundary condition at 3999ms: sample is fresh, metrics accumulate', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
final gpsFixTime = baseTime;
// Sample at 3950ms
fakeTelemetry.emitSample(createSample(
monotonicMs: 3950,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 3950,
speed: 45.0,
));
// Sample at 3999ms (age 3999ms <= 4000ms -> FRESH)
fakeTelemetry.emitSample(createSample(
monotonicMs: 3999,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 3999,
speed: 65.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(rideStateMachineProvider).topSpeedMph, 65.0);
});
test('5. Boundary condition at 4000ms: sample is fresh, metrics accumulate', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
final gpsFixTime = baseTime;
// Sample at 4000ms (age exactly 4000ms -> FRESH)
fakeTelemetry.emitSample(createSample(
monotonicMs: 4000,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 4000,
speed: 70.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(rideStateMachineProvider).topSpeedMph, 70.0);
});
test('6. Boundary condition at 4001ms: sample is stale, metrics do NOT accumulate', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
final gpsFixTime = baseTime;
// Baseline sample at 4000ms
fakeTelemetry.emitSample(createSample(
monotonicMs: 4000,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 4000,
speed: 55.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0);
// Boundary sample at 4001ms (age 4001ms > 4000ms -> STALE)
fakeTelemetry.emitSample(createSample(
monotonicMs: 4001,
gpsTimestamp: gpsFixTime,
gpsAgeMs: 4001,
speed: 95.0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Stale speed must NOT update top speed
expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0);
});
test('7. GNSS recovery after outage: distance does NOT jump across outage', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
// Fix 1 at t=0
fakeTelemetry.emitSample(createSample(
monotonicMs: 0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
speed: 30.0,
lat: 34.0000,
lon: -85.0000,
));
// Fix 1 frame at t=50ms
fakeTelemetry.emitSample(createSample(
monotonicMs: 50,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
speed: 30.0,
lat: 34.0001,
lon: -85.0000,
));
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceBeforeOutage = container.read(rideStateMachineProvider).tripDistanceMiles;
expect(distanceBeforeOutage, greaterThan(0.0));
// Outage: 10 seconds of stale samples at old coordinate
for (int i = 81; i <= 200; i++) {
fakeTelemetry.emitSample(createSample(
monotonicMs: i * 50,
gpsTimestamp: baseTime,
gpsAgeMs: i * 50, // 4050ms to 10000ms -> STALE
speed: 30.0,
lat: 34.0001,
lon: -85.0000,
));
}
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceDuringOutage = container.read(rideStateMachineProvider).tripDistanceMiles;
expect(distanceDuringOutage, equals(distanceBeforeOutage));
// RECOVERY: Frame N (first fresh fix 100m away)
final recoveryFixTime = baseTime.add(const Duration(milliseconds: 10050));
fakeTelemetry.emitSample(createSample(
monotonicMs: 10050,
gpsTimestamp: recoveryFixTime,
gpsAgeMs: 0,
speed: 30.0,
lat: 34.0010, // ~100m jump
lon: -85.0000,
));
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceAtRecoveryFrame = container.read(rideStateMachineProvider).tripDistanceMiles;
// Recovery frame MUST NOT integrate the 100m gap across the outage!
expect(distanceAtRecoveryFrame, equals(distanceBeforeOutage));
// Frame N+1: Next fresh sample 50ms later (real motion from recovery coordinate)
fakeTelemetry.emitSample(createSample(
monotonicMs: 10100,
gpsTimestamp: recoveryFixTime.add(const Duration(milliseconds: 50)),
gpsAgeMs: 0,
speed: 30.0,
lat: 34.00101, // ~1m move
lon: -85.0000,
));
await Future<void>.delayed(const Duration(milliseconds: 30));
final distanceAfterRecovery = container.read(rideStateMachineProvider).tripDistanceMiles;
// Normal accumulation resumes without the artificial 100m catch-up jump!
expect(distanceAfterRecovery, greaterThan(distanceBeforeOutage));
// Change should be tiny (~1 meter), NOT ~100 meters
final deltaMiles = distanceAfterRecovery - distanceBeforeOutage;
expect(deltaMiles, lessThan(0.01)); // 0.01 miles is 16 meters
});
test('8. Dynamic motorcycle metrics: cannot update max lean / G during GPS outage', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
// Fresh sample while riding normally
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 0,
speed: 30.0,
leanAngle: 15.0,
longitudinalG: 0.2,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0);
expect(container.read(rideStateMachineProvider).maxAccelG, 0.2);
// Bike enters garage / tunnel: GPS fix stops.
// Stale sample (> 4000ms) with cached speed (30.0 MPH) and extreme kickstand lean (45 deg)
fakeTelemetry.emitSample(createSample(
monotonicMs: 6000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 5000,
speed: 30.0, // cached raw speed
leanAngle: 45.0, // kickstand lean
longitudinalG: 1.5, // dropped phone / shake
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Stale speed must NOT satisfy dynamic moving gate; records must remain unchanged
expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0);
expect(container.read(rideStateMachineProvider).maxAccelG, 0.2);
});
test('9. High Speed event: not triggered repeatedly during GPS outage', () async {
final container = buildContainer();
final sm = container.read(rideStateMachineProvider.notifier);
await sm.startRide();
// Fresh sample at 105 MPH -> triggers High Speed event
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 0,
speed: 105.0,
));
await Future<void>.delayed(const Duration(milliseconds: 50));
final eventsInitial = await db.select(db.rideEvents).get();
expect(eventsInitial, hasLength(1));
expect(eventsInitial.first.eventType, 'High Speed');
// 10 seconds later, GPS is stale (> 4000ms), cached speed still 105 MPH
fakeTelemetry.emitSample(createSample(
monotonicMs: 11000,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 10000,
speed: 105.0,
));
await Future<void>.delayed(const Duration(milliseconds: 50));
final eventsAfterStale = await db.select(db.rideEvents).get();
// Must NOT add another High Speed event from stale speed!
expect(eventsAfterStale, hasLength(1));
});
});
group('Phase 1F — PerformanceLaunchStateMachine Stale GPS Gating', () {
late StreamController<TelemetrySample> perfController;
late FakeMonotonicClock clock;
PerformanceLaunchStateMachine? perfSm;
setUp(() {
perfController = StreamController<TelemetrySample>.broadcast();
clock = FakeMonotonicClock(
initialElapsedMs: 10000,
initialDateTime: baseTime,
);
});
tearDown(() async {
perfSm?.dispose();
await perfController.close();
});
test('10. PerformanceLaunchStateMachine in launched: aborts with gpsLost when GPS becomes stale', () async {
perfSm = PerformanceLaunchStateMachine(
db: db,
telemetryStream: perfController.stream,
clock: clock,
);
perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
// Stationary fresh sample -> READY
perfController.add(TelemetrySample(
timestamp: baseTime,
monotonicTimestampMs: 1000,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(perfSm!.state.state, PerformanceLaunchState.ready);
// Launch with fresh sample -> LAUNCHED
perfController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 100)),
monotonicTimestampMs: 1100,
speed: 5.0,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(perfSm!.state.state, PerformanceLaunchState.launched);
// Accelerating fresh sample
perfController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 1000)),
monotonicTimestampMs: 2000,
speed: 40.0,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(perfSm!.state.state, PerformanceLaunchState.launched);
// GPS stops advancing. Sample arrives with stale GPS (> 4000ms)
perfController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 6000)),
monotonicTimestampMs: 7000,
speed: 40.0,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
gpsAgeMs: 5000, // STALE
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// MUST abort with gpsLost!
expect(perfSm!.state.state, PerformanceLaunchState.aborted);
expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost);
});
test('11. PerformanceLaunchStateMachine in ready: does not launch from stale speed', () async {
perfSm = PerformanceLaunchStateMachine(
db: db,
telemetryStream: perfController.stream,
clock: clock,
);
perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
// Stationary fresh sample -> READY
perfController.add(TelemetrySample(
timestamp: baseTime,
monotonicTimestampMs: 1000,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(perfSm!.state.state, PerformanceLaunchState.ready);
// Stale sample with speed 15 MPH (> 1.0 MPH movement threshold) arrives
perfController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 5000)),
monotonicTimestampMs: 6000,
speed: 15.0,
gpsTimestamp: baseTime,
gpsAgeMs: 5000, // STALE
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Must NOT launch! Should revert to idle on stale GPS
expect(perfSm!.state.state, isNot(PerformanceLaunchState.launched));
expect(perfSm!.state.state, PerformanceLaunchState.idle);
});
test('12. PerformanceLaunchStateMachine in countdown: aborts with gpsLost when GPS becomes stale', () async {
perfSm = PerformanceLaunchStateMachine(
db: db,
telemetryStream: perfController.stream,
clock: clock,
);
perfSm!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
// Stationary fresh sample -> READY
perfController.add(TelemetrySample(
timestamp: baseTime,
monotonicTimestampMs: 1000,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(perfSm!.state.state, PerformanceLaunchState.ready);
// Start countdown
perfSm!.startCountdown();
expect(perfSm!.state.state, PerformanceLaunchState.countdown);
// Stale sample arrives during countdown
perfController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 5000)),
monotonicTimestampMs: 6000,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 5000, // STALE
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Must abort with gpsLost!
expect(perfSm!.state.state, PerformanceLaunchState.aborted);
expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost);
});
});
group('Phase 1F — PerformanceTimerService Stale GPS Gating', () {
late StreamController<TelemetrySample> timerController;
PerformanceTimerService? timerSm;
setUp(() {
timerController = StreamController<TelemetrySample>.broadcast();
});
tearDown(() async {
timerSm?.dispose();
await timerController.close();
});
test('13. PerformanceTimerService in timing: aborts when GPS becomes stale', () async {
timerSm = PerformanceTimerService(
db,
timerController.stream,
);
timerSm!.setTarget(PerformanceTarget.sixty);
// Stationary fresh sample -> READY
timerController.add(TelemetrySample(
timestamp: baseTime,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(timerSm!.state.state, PerformanceState.ready);
// Launch with fresh sample -> TIMING
timerController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 100)),
speed: 5.0,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
gpsAgeMs: 0,
));
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(timerSm!.state.state, PerformanceState.timing);
// Stale sample arrives (> 4000ms)
timerController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 6000)),
speed: 40.0,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
gpsAgeMs: 5900, // STALE
));
await Future<void>.delayed(const Duration(milliseconds: 20));
// Must abort!
expect(timerSm!.state.state, PerformanceState.aborted);
// And no run should be saved in the database
final runs = await db.select(db.performanceRuns).get();
expect(runs, isEmpty);
});
});
}

View File

@ -0,0 +1,158 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
void main() {
final base = DateTime.utc(2026, 9, 19);
test(
'normal READY launch retains interpolation fallback and null-speed handling',
() async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final controller = StreamController<TelemetrySample>(sync: true);
final timer = PerformanceTimerService(db, controller.stream);
addTearDown(() async {
timer.dispose();
await controller.close();
await db.close();
});
timer.setTarget(PerformanceTarget.sixty);
for (final age in <int?>[null, 0, 4001]) {
controller.add(TelemetrySample(timestamp: base, gpsAgeMs: age));
expect(timer.state.state, PerformanceState.ready);
}
// No previous speed sample: launch time falls back to this timestamp.
controller.add(TelemetrySample(timestamp: base, speed: 5, gpsAgeMs: 0));
expect(timer.state.state, PerformanceState.timing);
for (final age in <int?>[null, 0, 4001]) {
controller.add(
TelemetrySample(
timestamp: base.add(const Duration(milliseconds: 100)),
gpsAgeMs: age,
),
);
expect(timer.state.state, PerformanceState.timing);
}
controller.add(
TelemetrySample(
timestamp: base.add(const Duration(seconds: 3)),
speed: 60,
gpsAgeMs: 0,
),
);
expect(timer.state.state, PerformanceState.completed);
final runs = await db.select(db.performanceRuns).get();
expect(runs, hasLength(1));
expect(runs.single.timeMs, 3000);
},
);
test('stale READY requires new stationary evidence before launch', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final controller = StreamController<TelemetrySample>(sync: true);
final timer = PerformanceTimerService(db, controller.stream);
addTearDown(() async {
timer.dispose();
await controller.close();
await db.close();
});
void emit(int ms, double speed, int age) {
controller.add(
TelemetrySample(
timestamp: base.add(Duration(milliseconds: ms)),
speed: speed,
gpsAgeMs: age,
),
);
}
timer.setTarget(PerformanceTarget.sixty);
emit(0, 0, 0);
expect(timer.state.state, PerformanceState.ready);
emit(4000, 0, 4000);
expect(timer.state.state, PerformanceState.ready);
emit(4001, 0, 4001);
expect(timer.state.state, PerformanceState.idle);
emit(5000, 30, 5000);
emit(6000, 40, 0);
expect(timer.state.state, PerformanceState.idle);
emit(9000, 65, 0);
expect(timer.state.state, PerformanceState.idle);
expect(await db.select(db.performanceRuns).get(), isEmpty);
emit(10000, 0, 0);
expect(timer.state.state, PerformanceState.ready);
emit(10200, 5, 0);
expect(timer.state.state, PerformanceState.timing);
emit(13000, 65, 0);
expect(timer.state.state, PerformanceState.completed);
final runs = await db.select(db.performanceRuns).get();
expect(runs, hasLength(1));
expect(runs.single.timeMs, closeTo(2727, 1));
});
for (final coordinates in <(double?, double?)>[
(null, null),
(null, 0),
(0, null),
(double.nan, 0),
(0, double.nan),
(double.infinity, 0),
(0, double.negativeInfinity),
(90.1, 0),
(-90.1, 0),
(0, 180.1),
(0, -180.1),
]) {
for (final metadata in ['absent', 'age', 'timestamp']) {
test('invalid coordinates $coordinates with $metadata are not fresh', () {
expect(
TelemetrySample(
timestamp: base,
latitude: coordinates.$1,
longitude: coordinates.$2,
gpsAgeMs: metadata == 'age' ? 0 : null,
gpsTimestamp: metadata == 'timestamp' ? base : null,
).isGpsFresh,
isFalse,
);
});
}
}
for (final age in [3999, 4000, 4001]) {
for (final useAge in [true, false]) {
test('GPS boundary $age with explicit age $useAge', () {
expect(
TelemetrySample(
timestamp: base,
latitude: 0,
longitude: 0,
gpsAgeMs: useAge ? age : null,
gpsTimestamp: useAge
? null
: base.subtract(Duration(milliseconds: age)),
).isGpsFresh,
age <= 4000,
);
});
}
}
test('valid coordinate limits retain metadata-free compatibility', () {
for (final coordinates in [(90.0, 180.0), (-90.0, -180.0), (0.0, 0.0)]) {
expect(
TelemetrySample(
timestamp: base,
latitude: coordinates.$1,
longitude: coordinates.$2,
).isGpsFresh,
isTrue,
);
}
});
}

View File

@ -0,0 +1,458 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/models/recording_health_status.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/records/records_service.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:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart'
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
import 'package:drift/native.dart';
class MockAudioAlertService extends Fake implements AudioAlertService {
@override
Future<void> speak(String text, {bool force = false}) async {}
}
class FakeTelemetryService extends TelemetryService {
final StreamController<TelemetrySample> _controller =
StreamController<TelemetrySample>.broadcast();
FakeTelemetryService(
super.loc,
super.fusion,
super.compass,
super.logger,
super.validator,
);
@override
Stream<TelemetrySample> get telemetryStream => _controller.stream;
void emitSample(TelemetrySample sample) => _controller.add(sample);
@override
void dispose() => _controller.close();
}
class _NullLocationService extends Fake implements LocationService {
@override
void Function(Position position, bool accepted, String? rejectionReason)?
onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => const Stream.empty();
}
class _NullSensorFusionService extends Fake implements SensorFusionService {
@override
Stream<double> get leanAngleStream => const Stream.empty();
@override
Stream<double> get pitchAngleStream => const Stream.empty();
@override
Stream<GForceData> get gForceStream => const Stream.empty();
@override
double get latestRollRateDegPerSec => 0.0;
@override
double get latestRawAccelX => 0.0;
@override
double get latestRawAccelY => 0.0;
@override
double get latestRawAccelZ => 0.0;
@override
double get latestRawGyroX => 0.0;
@override
double get latestRawGyroY => 0.0;
@override
double get latestRawGyroZ => 0.0;
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const tickWait = Duration(milliseconds: 3300);
const evidenceStaleMs = RecordingHealthContract.evidenceStaleAfterMs; // 4000ms
late AppDatabase db;
late FieldTestLogger logger;
late FakeTelemetryService fakeTelemetry;
late MockAudioAlertService mockAudio;
late TelemetryValidator validator;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeTelemetry = FakeTelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
validator,
);
mockAudio = MockAudioAlertService();
});
tearDown(() async {
fakeTelemetry.dispose();
logger.dispose();
await db.close();
});
ProviderContainer buildContainer() {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
return container;
}
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
TelemetrySample createSample({
required int monotonicMs,
DateTime? gpsTimestamp,
int? gpsAgeMs,
DateTime? imuTimestamp,
double speed = 35.0,
}) {
return TelemetrySample(
timestamp: baseTime.add(Duration(milliseconds: monotonicMs)),
monotonicTimestampMs: monotonicMs,
latitude: 34.0,
longitude: -85.0,
speed: speed,
gpsTimestamp: gpsTimestamp,
gpsAgeMs: gpsAgeMs,
imuTimestamp: imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)),
leanAngle: 0.0,
pitchAngle: 0.0,
longitudinalG: 0.0,
lateralG: 0.0,
);
}
Future<void> reachHealthyActive(ProviderContainer container) async {
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Initial evidence at monotonic 1000ms
final t1 = baseTime;
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: t1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
// Wait for periodic batch write timer (3.0s) to commit to DB
await Future<void>.delayed(tickWait);
await pumpEventQueue(times: 20);
// Re-establish sensor freshness
final t2 = baseTime.add(tickWait);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000 + tickWait.inMilliseconds,
gpsTimestamp: t2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingActive);
}
group('Phase 1D: Stale-GPS Freshness Semantics', () {
test('A. Duplicate GPS timestamp does NOT refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
// Sample 1: first GPS fix at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Subsequent samples at M2 = 2000, M3 = 3000, M4 = 5500 all carry same gpsT1
fakeTelemetry.emitSample(createSample(
monotonicMs: 2000,
gpsTimestamp: gpsT1,
gpsAgeMs: 1000,
));
await pumpEventQueue(times: 10);
// Still within 4000ms evidence window from M1=1000 (age is 1000ms)
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// At M4 = 5500, elapsed monotonic time since M1 (1000) is 4500ms > evidenceStaleAfterMs (4000)
// Because gpsT1 was repeated, GPS evidence was NOT refreshed.
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: gpsT1,
gpsAgeMs: 4500,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Duplicate GPS timestamp must not refresh GPS evidence; evidence must be stale');
});
test('B. Genuine newer GPS timestamp refreshes GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
final gpsT2 = baseTime.add(const Duration(seconds: 4));
// Sample 1: GPS T1 at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
// Sample 2: Genuine newer GPS T2 at M2 = 5000 (4000ms later)
fakeTelemetry.emitSample(createSample(
monotonicMs: 5000,
gpsTimestamp: gpsT2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Sample 3 at M3 = 7000 (age relative to M2 is 2000ms <= 4000ms)
fakeTelemetry.emitSample(createSample(
monotonicMs: 7000,
gpsTimestamp: gpsT2,
gpsAgeMs: 2000,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
reason: 'T2 refreshed GPS evidence at M2=5000, so at M3=7000 it is only 2000ms old and still fresh');
});
test('C. Older/out-of-order GPS timestamp does not refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
final gpsT2 = baseTime.add(const Duration(seconds: 2));
// Accept T2 at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT2,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Out-of-order older sample carrying T1 (< T2) arrives at M2 = 5500
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: gpsT1,
gpsAgeMs: 4500,
));
await pumpEventQueue(times: 10);
// Monotonic age from M1(1000) to M2(5500) is 4500ms > 4000ms.
// Older T1 must not refresh GPS evidence.
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Older out-of-order GPS timestamp must not refresh GPS evidence');
});
test('D. Null GPS timestamp does not refresh GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final gpsT1 = baseTime;
// Sample with GPS at M1 = 1000
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: gpsT1,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
// Sample without GPS at M2 = 5500
fakeTelemetry.emitSample(createSample(
monotonicMs: 5500,
gpsTimestamp: null,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
reason: 'Null GPS timestamp must not refresh GPS evidence');
});
test('E. Normal 10 Hz / 20 Hz operation maintains healthy GPS evidence', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Simulate 10 GPS fixes (100ms apart), each sampled twice at 20 Hz (50ms interval)
int monotonic = 1000;
int totalEmitted = 0;
for (int fix = 0; fix < 10; fix++) {
final fixTime = baseTime.add(Duration(milliseconds: fix * 100));
// First sample for this fix (0ms age)
fakeTelemetry.emitSample(createSample(
monotonicMs: monotonic,
gpsTimestamp: fixTime,
gpsAgeMs: 0,
));
monotonic += 50;
totalEmitted++;
// Second sample for this fix (repackaged cached fix, 50ms age)
fakeTelemetry.emitSample(createSample(
monotonicMs: monotonic,
gpsTimestamp: fixTime,
gpsAgeMs: 50,
));
monotonic += 50;
totalEmitted++;
}
await pumpEventQueue(times: 30);
final session = container.read(rideStateMachineProvider);
expect(session.totalSamplesReceived, totalEmitted,
reason: 'Every 20 Hz telemetry sample must be accepted');
expect(session.health.isForegroundServiceAlive, isTrue,
reason: 'Normal 10 Hz acquisition with 20 Hz sampling must remain healthy');
});
test('F. GPS outage transitions health to recordingDegraded', () async {
final container = buildContainer();
await reachHealthyActive(container);
// Starting state: recordingActive
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingActive);
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
final cachedGpsTime = baseTime.add(tickWait);
// Outage occurs: GPS fixes stop, but 20 Hz telemetry loop continues emitting cached fix
// Emit samples advancing monotonic time past the 4000ms staleness threshold
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500; // 4500ms after last fix
fakeTelemetry.emitSample(createSample(
monotonicMs: outageMonotonic,
gpsTimestamp: cachedGpsTime, // Stale fix
gpsAgeMs: evidenceStaleMs + 500,
));
await pumpEventQueue(times: 20);
final degradedSession = container.read(rideStateMachineProvider);
expect(degradedSession.health.isForegroundServiceAlive, isFalse);
expect(degradedSession.health.canReportRecording, isFalse);
expect(degradedSession.healthStatus, RecordingHealthStatus.recordingDegraded,
reason: 'GPS outage past 4000ms threshold must transition health to recordingDegraded');
expect(degradedSession.state, RideState.riding,
reason: 'Degradation must not cancel or abort the active ride');
});
test('G. GPS recovery restores recordingActive from recordingDegraded', () async {
final container = buildContainer();
await reachHealthyActive(container);
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
final cachedGpsTime = baseTime.add(tickWait);
// 1. Induce outage
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500;
fakeTelemetry.emitSample(createSample(
monotonicMs: outageMonotonic,
gpsTimestamp: cachedGpsTime,
gpsAgeMs: evidenceStaleMs + 500,
));
await pumpEventQueue(times: 20);
expect(container.read(rideStateMachineProvider).healthStatus,
RecordingHealthStatus.recordingDegraded);
// 2. Recovery: GPS acquires fresh fix
final recoveredGpsTime = cachedGpsTime.add(const Duration(seconds: 5));
final recoveredMonotonic = outageMonotonic + 50;
fakeTelemetry.emitSample(createSample(
monotonicMs: recoveredMonotonic,
gpsTimestamp: recoveredGpsTime, // Genuinely newer fix
gpsAgeMs: 0,
));
await pumpEventQueue(times: 20);
final recoveredSession = container.read(rideStateMachineProvider);
expect(recoveredSession.health.isForegroundServiceAlive, isTrue);
expect(recoveredSession.health.canReportRecording, isTrue);
expect(recoveredSession.healthStatus, RecordingHealthStatus.recordingActive,
reason: 'Fresh GPS fix must recover health from degraded back to recordingActive');
});
test('H. Session reset / start clears last seen GPS timestamp', () async {
final container = buildContainer();
final notifier = container.read(rideStateMachineProvider.notifier);
// Ride 1
await notifier.startRide();
final futureGpsTime = DateTime(2030, 1, 1, 12, 0, 0);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: futureGpsTime,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
await notifier.finishRide();
await pumpEventQueue(times: 20);
// Ride 2 starts with normal current time (earlier than Ride 1's future timestamp)
await notifier.startRide();
final normalGpsTime = DateTime(2026, 9, 19, 12, 0, 0);
fakeTelemetry.emitSample(createSample(
monotonicMs: 1000,
gpsTimestamp: normalGpsTime,
gpsAgeMs: 0,
));
await pumpEventQueue(times: 10);
// Must be accepted as fresh evidence for the new ride!
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
reason: 'New ride start must clear _lastSeenGpsTimestamp so previous ride timestamps are not inherited');
});
});
}

View File

@ -0,0 +1,508 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_map_panel.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.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/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
import 'package:motorcycle_dash/database/database.dart'
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
import 'package:drift/native.dart';
class FakeLocationService extends Fake implements LocationService {
final StreamController<Position> _controller =
StreamController<Position>.broadcast();
@override
void Function(Position position, bool accepted, String? rejectionReason)?
onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => _controller.stream;
void emitPosition(Position position) => _controller.add(position);
@override
void dispose() => _controller.close();
}
class FakeCompassService extends Fake implements CompassService {
final StreamController<double> _controller =
StreamController<double>.broadcast();
@override
Stream<double> get headingStream => _controller.stream;
void emitHeading(double heading) => _controller.add(heading);
@override
void dispose() => _controller.close();
}
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;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FieldTestLogger logger;
late TelemetryValidator validator;
late FakeLocationService fakeLocation;
late FakeCompassService fakeCompass;
late TelemetryService telemetryService;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeLocation = FakeLocationService();
fakeCompass = FakeCompassService();
telemetryService = TelemetryService(
fakeLocation,
_NullSensorFusionService(),
fakeCompass,
logger,
validator,
);
});
tearDown(() async {
telemetryService.dispose();
fakeLocation.dispose();
fakeCompass.dispose();
logger.dispose();
await db.close();
});
Position createPosition({
required DateTime timestamp,
double speedMps = 20.0, // ~45 MPH
double heading = 90.0,
double lat = 34.0522,
double lon = -118.2437,
double accuracy = 5.0,
}) {
return Position(
latitude: lat,
longitude: lon,
timestamp: timestamp,
altitude: 100.0,
altitudeAccuracy: 1.0,
accuracy: accuracy,
heading: heading,
headingAccuracy: 1.0,
speed: speedMps,
speedAccuracy: 1.0,
);
}
group('Phase 1G — Heading Selection Truthfulness in TelemetryService', () {
test('1. Fresh moving GPS heading remains usable and preferred over compass', () async {
final now = DateTime.now();
// Emit live compass = 180°
fakeCompass.emitHeading(180.0);
// Emit fresh GPS fix moving at 45 MPH heading 90°
fakeLocation.emitPosition(createPosition(
timestamp: now,
speedMps: 20.0,
heading: 90.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final sample = await telemetryService.telemetryStream.first;
// Moving GPS heading (90°) should override compass (180°)
expect(sample.heading, 90.0);
expect(sample.speed, greaterThan(3.0));
expect(sample.isGpsFresh, isTrue);
});
test('2. Normal duplicate GPS fixes (10 Hz / 20 Hz) do not cause heading flicker', () async {
final now = DateTime.now();
fakeCompass.emitHeading(220.0);
// Fix arrives 100ms ago (simulating 10 Hz acquisition sampled at 20 Hz)
fakeLocation.emitPosition(createPosition(
timestamp: now.subtract(const Duration(milliseconds: 100)),
speedMps: 15.0,
heading: 75.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final sample = await telemetryService.telemetryStream.first;
// Within 4000ms bound, duplicate fix heading remains stable
expect(sample.heading, 75.0);
expect(sample.isGpsFresh, isTrue);
});
test('3. Stale GPS heading stops overriding live compass after 4000ms', () async {
final now = DateTime.now();
// Live compass is 270° West
fakeCompass.emitHeading(270.0);
// GPS fix is stale (> 4000ms ago, e.g. 5000ms ago) with heading 90° East
fakeLocation.emitPosition(createPosition(
timestamp: now.subtract(const Duration(milliseconds: 5000)),
speedMps: 20.0,
heading: 90.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final sample = await telemetryService.telemetryStream.first;
// Stale GPS must NOT override compass! Heading must fall back to 270°
expect(sample.heading, 270.0);
expect(sample.isGpsFresh, isFalse);
});
test('4. Heading recovers automatically when fresh GPS resumes', () async {
final now = DateTime.now();
fakeCompass.emitHeading(180.0);
// Initially stale GPS (5000ms ago, heading 90°)
fakeLocation.emitPosition(createPosition(
timestamp: now.subtract(const Duration(milliseconds: 5000)),
speedMps: 20.0,
heading: 90.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final staleSample = await telemetryService.telemetryStream.first;
expect(staleSample.heading, 180.0); // Compass used
expect(staleSample.isGpsFresh, isFalse);
// Now fresh GPS fix arrives: heading 120°, moving
final freshNow = DateTime.now();
fakeLocation.emitPosition(createPosition(
timestamp: freshNow,
speedMps: 22.0,
heading: 120.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final recoveredSample = await telemetryService.telemetryStream.first;
// Fresh GPS automatically becomes eligible again!
expect(recoveredSample.heading, 120.0);
expect(recoveredSample.isGpsFresh, isTrue);
});
test('5. Heading and GPS boundary condition at 3999ms, 4000ms, and 4001ms', () {
final base = DateTime(2026, 9, 19, 12, 0, 0);
// A. 3999 ms -> FRESH
final sample3999 = TelemetrySample(
timestamp: base,
latitude: 34.0,
longitude: -85.0,
speed: 40.0,
heading: 45.0,
gpsTimestamp: base.subtract(const Duration(milliseconds: 3999)),
gpsAgeMs: 3999,
);
expect(sample3999.isGpsFresh, isTrue);
// B. 4000 ms -> FRESH (inclusive canonical bound)
final sample4000 = TelemetrySample(
timestamp: base,
latitude: 34.0,
longitude: -85.0,
speed: 40.0,
heading: 45.0,
gpsTimestamp: base.subtract(const Duration(milliseconds: 4000)),
gpsAgeMs: 4000,
);
expect(sample4000.isGpsFresh, isTrue);
// C. 4001 ms -> STALE (exceeds canonical bound)
final sample4001 = TelemetrySample(
timestamp: base,
latitude: 34.0,
longitude: -85.0,
speed: 40.0,
heading: 45.0,
gpsTimestamp: base.subtract(const Duration(milliseconds: 4001)),
gpsAgeMs: 4001,
);
expect(sample4001.isGpsFresh, isFalse);
});
test('6. Fresh GPS at stationary/low speed (<= 3.0 MPH) uses compass', () async {
fakeCompass.emitHeading(42.0);
fakeLocation.emitPosition(createPosition(
timestamp: DateTime.now(),
speedMps: 0.5, // ~1.1 MPH <= 3.0 MPH
heading: 90.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final sample = await telemetryService.telemetryStream.first;
// Low speed correctly preserves compass fallback
expect(sample.heading, 42.0);
});
test('7. Raw telemetry values (lat, lon, gpsTimestamp, gpsAgeMs) remain preserved during stale GPS', () async {
final fixTime = DateTime.now().subtract(const Duration(milliseconds: 7000));
fakeCompass.emitHeading(180.0);
fakeLocation.emitPosition(createPosition(
timestamp: fixTime,
lat: 37.7749,
lon: -122.4194,
speedMps: 20.0,
heading: 90.0,
));
await Future<void>.delayed(const Duration(milliseconds: 100));
final sample = await telemetryService.telemetryStream.first;
// Raw values must NOT be nulled or altered
expect(sample.latitude, 37.7749);
expect(sample.longitude, -122.4194);
expect(sample.gpsTimestamp, fixTime);
expect(sample.gpsAgeMs, greaterThanOrEqualTo(7000));
// But freshness flags truthful status
expect(sample.isGpsFresh, isFalse);
expect(sample.isSpeedFresh, isFalse);
});
});
group('Phase 1G — DashboardMapPanel & Location Presentation Truthfulness', () {
late StreamController<TelemetrySample> testStreamController;
setUp(() {
testStreamController = StreamController<TelemetrySample>.broadcast();
});
tearDown(() async {
await testStreamController.close();
});
Widget buildTestWidget({required Stream<TelemetrySample> stream}) {
return ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith((ref) => stream),
activeAlertsStreamProvider.overrideWith((ref) => Stream.value(<RoadAlert>[])),
],
child: const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
height: 400,
child: DashboardMapPanel(),
),
),
),
);
}
testWidgets('8. Fresh map coordinate renders normal active marker without stale badge', (tester) async {
final now = DateTime.now();
final freshSample = TelemetrySample(
timestamp: now,
latitude: 34.0522,
longitude: -118.2437,
heading: 90.0,
speed: 35.0,
gpsTimestamp: now,
gpsAgeMs: 50,
);
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
testStreamController.add(freshSample);
await tester.pumpAndSettle();
// Stale pill badge must NOT be present
expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing);
expect(find.text('LAST KNOWN LOCATION'), findsNothing);
// Map view is rendered
expect(find.byType(UnifiedMapView), findsOneWidget);
});
testWidgets('9. Stale map coordinate renders LAST KNOWN LOCATION pill badge and dimmed marker', (tester) async {
final now = DateTime.now();
final staleSample = TelemetrySample(
timestamp: now,
latitude: 34.0522,
longitude: -118.2437,
heading: 90.0,
speed: 35.0,
gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)),
gpsAgeMs: 6000, // > 4000ms -> STALE
);
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
testStreamController.add(staleSample);
await tester.pumpAndSettle();
// Stale pill badge MUST be present
expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget);
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
expect(find.byIcon(Icons.location_off), findsOneWidget);
});
testWidgets('10. Stale coordinate does NOT move artificially (no dead reckoning / fake drift)', (tester) async {
final now = DateTime.now();
const initialLat = 34.0522;
const initialLon = -118.2437;
// 5 seconds of stale samples at 20 Hz
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
for (int i = 0; i < 10; i++) {
testStreamController.add(TelemetrySample(
timestamp: now.add(Duration(milliseconds: i * 50)),
latitude: initialLat,
longitude: initialLon,
heading: 90.0,
speed: 45.0, // Cached speed
gpsTimestamp: now.subtract(const Duration(milliseconds: 5000)),
gpsAgeMs: 5000 + (i * 50),
));
}
await tester.pumpAndSettle();
// Map center must remain exactly initialLat, initialLon
final mapWidget = tester.widget<UnifiedMapView>(find.byType(UnifiedMapView));
expect(mapWidget.center.latitude, initialLat);
expect(mapWidget.center.longitude, initialLon);
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
});
testWidgets('11. Map presentation recovers automatically when fresh GPS resumes', (tester) async {
final now = DateTime.now();
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
// 1. Emit stale sample
testStreamController.add(TelemetrySample(
timestamp: now,
latitude: 34.0522,
longitude: -118.2437,
heading: 90.0,
speed: 35.0,
gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)),
gpsAgeMs: 6000,
));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget);
// 2. Emit fresh sample (GPS recovered)
final recoveredNow = DateTime.now();
testStreamController.add(TelemetrySample(
timestamp: recoveredNow,
latitude: 34.0530,
longitude: -118.2430,
heading: 95.0,
speed: 35.0,
gpsTimestamp: recoveredNow,
gpsAgeMs: 10,
));
await tester.pumpAndSettle();
// Stale badge disappears automatically
expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing);
expect(find.text('LAST KNOWN LOCATION'), findsNothing);
// Coordinates updated to fresh location
final mapWidget = tester.widget<UnifiedMapView>(find.byType(UnifiedMapView));
expect(mapWidget.center.latitude, 34.0530);
expect(mapWidget.center.longitude, -118.2430);
});
testWidgets('12. Truthfulness operates independently of ride state (outside an active ride)', (tester) async {
// Without any ride session started or active, freshness and stale badge work directly from telemetry stream
final now = DateTime.now();
final staleSample = TelemetrySample(
timestamp: now,
latitude: 40.7128,
longitude: -74.0060,
heading: 180.0,
speed: 0.0,
gpsTimestamp: now.subtract(const Duration(milliseconds: 5500)),
gpsAgeMs: 5500,
);
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
testStreamController.add(staleSample);
await tester.pumpAndSettle();
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
});
testWidgets('13. GPSStatusIndicator truthfully shows NO GPS when fix is stale (> 4000ms)', (tester) async {
final now = DateTime.now();
Widget buildGpsWidget({required Stream<TelemetrySample> stream}) {
return ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith((ref) => stream),
],
child: const MaterialApp(
home: Scaffold(
body: GPSStatusIndicator(),
),
),
);
}
// 1. Fresh fix with accuracy = 5.0 -> GPS 3D FIX
await tester.pumpWidget(buildGpsWidget(stream: testStreamController.stream));
testStreamController.add(TelemetrySample(
timestamp: now,
latitude: 34.0,
longitude: -85.0,
gpsAccuracy: 5.0,
gpsTimestamp: now,
gpsAgeMs: 50,
));
await tester.pumpAndSettle();
expect(find.text('GPS 3D FIX'), findsOneWidget);
// 2. Stale fix with accuracy = 5.0 (> 4000ms) -> NO GPS
testStreamController.add(TelemetrySample(
timestamp: now.add(const Duration(seconds: 5)),
latitude: 34.0,
longitude: -85.0,
gpsAccuracy: 5.0, // cached accuracy
gpsTimestamp: now,
gpsAgeMs: 5000, // STALE
));
await tester.pumpAndSettle();
expect(find.text('NO GPS'), findsOneWidget);
expect(find.byIcon(Icons.gps_off), findsOneWidget);
});
});
}

View File

@ -0,0 +1,306 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
group('Phase 1E: TelemetrySample Speed Freshness Semantic Model', () {
test('A. Normal moving GPS provides fresh speed', () {
final sample = TelemetrySample(
timestamp: baseTime,
speed: 50.0,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
);
expect(sample.isSpeedFresh, isTrue);
expect(sample.freshSpeed, 50.0);
});
test('B. Legitimate 10 Hz / 20 Hz duplication remains fresh within 4000ms bound', () {
final sample1 = TelemetrySample(
timestamp: baseTime,
speed: 50.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
);
final sample2 = TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 50)),
speed: 50.0,
gpsTimestamp: baseTime, // Cached fix reused 50ms later
gpsAgeMs: 50,
);
expect(sample1.isSpeedFresh, isTrue);
expect(sample1.freshSpeed, 50.0);
expect(sample2.isSpeedFresh, isTrue);
expect(sample2.freshSpeed, 50.0);
});
test('C & D. GNSS outage: stale speed becomes untrustworthy and does not fabricate zero', () {
final staleSample = TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 5)),
speed: 50.0, // Cached speed from 5s ago
gpsTimestamp: baseTime,
gpsAgeMs: 5000, // Exceeds 4000ms threshold
);
expect(staleSample.isSpeedFresh, isFalse,
reason: 'GPS fix age > 4000ms must be marked as stale');
expect(staleSample.freshSpeed, isNull,
reason: 'Stale speed must be null, never presenting old speed as current');
expect(staleSample.speed, 50.0,
reason: 'Underlying raw speed is preserved for forensic analysis');
expect(staleSample.freshSpeed, isNot(0.0),
reason: 'Must NOT fabricate 0.0 MPH for a stale condition');
});
test('E. Genuine stopped motorcycle is distinguishable from stale/unavailable GPS', () {
final stoppedSample = TelemetrySample(
timestamp: baseTime,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
);
expect(stoppedSample.isSpeedFresh, isTrue);
expect(stoppedSample.freshSpeed, 0.0,
reason: 'Fresh 0.0 MPH must be recognized as valid zero-speed measurement');
});
test('F. Recovery from outage produces fresh speed again', () {
final recoveredSample = TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 10)),
speed: 47.0,
gpsTimestamp: baseTime.add(const Duration(seconds: 10)),
gpsAgeMs: 0,
);
expect(recoveredSample.isSpeedFresh, isTrue);
expect(recoveredSample.freshSpeed, 47.0);
});
test('Null speed fix is not fresh', () {
final nullSpeedSample = TelemetrySample(
timestamp: baseTime,
speed: null,
);
expect(nullSpeedSample.isSpeedFresh, isFalse);
expect(nullSpeedSample.freshSpeed, isNull);
});
});
group('Phase 1E: SpeedReadout Widget Presentation Tests', () {
late StreamController<TelemetrySample> telemetryController;
setUp(() {
telemetryController = StreamController<TelemetrySample>.broadcast();
});
tearDown(() {
telemetryController.close();
});
Widget buildTestHarness({SpeedReadoutSize size = SpeedReadoutSize.regular}) {
return ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
],
child: MaterialApp(
home: Scaffold(
body: Center(
child: SpeedReadout(size: size),
),
),
),
);
}
testWidgets('Initial state before telemetry emits shows 0', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump();
expect(find.text('0'), findsOneWidget);
expect(find.text('MPH'), findsOneWidget);
});
testWidgets('A. Normal moving GPS renders fresh numerical speed', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump();
// Emit 48 MPH
telemetryController.add(TelemetrySample(
timestamp: baseTime,
speed: 48.2,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('48'), findsOneWidget);
// Emit 50 MPH
telemetryController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(milliseconds: 100)),
speed: 50.1,
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
gpsAgeMs: 50,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('50'), findsOneWidget);
});
testWidgets('B. Legitimate 10 Hz / 20 Hz duplication renders smoothly without dropping to --', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump(const Duration(milliseconds: 50));
final t1 = baseTime;
// Tick 1: New fix T1 (50 MPH)
telemetryController.add(TelemetrySample(
timestamp: t1,
speed: 50.0,
gpsTimestamp: t1,
gpsAgeMs: 0,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('50'), findsOneWidget);
// Tick 2: 50ms later, repackaged T1 (50 MPH, age 50ms)
telemetryController.add(TelemetrySample(
timestamp: t1.add(const Duration(milliseconds: 50)),
speed: 50.0,
gpsTimestamp: t1,
gpsAgeMs: 50,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('50'), findsOneWidget,
reason: 'Intermediate 20 Hz duplicate must continue displaying 50 smoothly');
expect(find.text('--'), findsNothing);
});
testWidgets('C & D. GNSS outage displays -- and does NOT fabricate 0 or keep 50 MPH', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump(const Duration(milliseconds: 50));
// Initial fresh 50 MPH
telemetryController.add(TelemetrySample(
timestamp: baseTime,
speed: 50.0,
gpsTimestamp: baseTime,
gpsAgeMs: 0,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('50'), findsOneWidget);
// GPS outage: 5 seconds elapse without new GPS fix
telemetryController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 5)),
speed: 50.0, // Stale cached speed
gpsTimestamp: baseTime,
gpsAgeMs: 5000, // Stale age > 4000ms
));
await tester.pump(const Duration(milliseconds: 50));
// UI must no longer display 50
expect(find.text('50'), findsNothing,
reason: 'Stale 50 MPH speed must not remain displayed during an outage');
// UI must display '--'
expect(find.text('--'), findsOneWidget,
reason: 'Stale speed must be rendered as "--" placeholder');
// UI must NOT fabricate '0'
expect(find.text('0'), findsNothing,
reason: 'Stale speed must NOT be fabricated as 0 MPH');
});
testWidgets('E. Genuine stopped motorcycle renders 0, distinguishable from --', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump(const Duration(milliseconds: 50));
// Fresh 0.0 MPH fix
telemetryController.add(TelemetrySample(
timestamp: baseTime,
speed: 0.0,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('0'), findsOneWidget,
reason: 'Fresh stopped reading must display "0"');
expect(find.text('--'), findsNothing);
});
testWidgets('F. Recovery from outage restores speed display automatically', (tester) async {
await tester.pumpWidget(buildTestHarness());
await tester.pump(const Duration(milliseconds: 50));
// 1. Enter outage
telemetryController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 5)),
speed: 50.0,
gpsTimestamp: baseTime,
gpsAgeMs: 5000,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('--'), findsOneWidget);
// 2. Recovery fix arrives with 47 MPH
telemetryController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 6)),
speed: 47.0,
gpsTimestamp: baseTime.add(const Duration(seconds: 6)),
gpsAgeMs: 0,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('47'), findsOneWidget,
reason: 'Recovery must automatically display fresh speed without user intervention');
expect(find.text('--'), findsNothing);
});
testWidgets('G. Ride-independent behavior: works outside an active ride', (tester) async {
// Build test harness with rideStateMachineProvider in idle state (not riding)
final harness = ProviderScope(
overrides: [
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
],
child: MaterialApp(
home: Scaffold(
body: SpeedReadout(),
),
),
);
await tester.pumpWidget(harness);
await tester.pump(const Duration(milliseconds: 50));
// Outside a ride, fresh speed renders normally
telemetryController.add(TelemetrySample(
timestamp: baseTime,
speed: 35.0,
gpsTimestamp: baseTime,
gpsAgeMs: 50,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('35'), findsOneWidget);
// Outside a ride, stale speed becomes '--'
telemetryController.add(TelemetrySample(
timestamp: baseTime.add(const Duration(seconds: 5)),
speed: 35.0,
gpsTimestamp: baseTime,
gpsAgeMs: 5000,
));
await tester.pump(const Duration(milliseconds: 50));
expect(find.text('--'), findsOneWidget);
});
});
}