diff --git a/docs/CODE_MAP.md b/docs/CODE_MAP.md new file mode 100644 index 0000000..e6d2600 --- /dev/null +++ b/docs/CODE_MAP.md @@ -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` watches `telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0)`. + - 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. diff --git a/docs/DEBUGGING_MAP.md b/docs/DEBUGGING_MAP.md new file mode 100644 index 0000000..6062a49 --- /dev/null +++ b/docs/DEBUGGING_MAP.md @@ -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`. diff --git a/lib/features/dashboard/widgets/dashboard_map_panel.dart b/lib/features/dashboard/widgets/dashboard_map_panel.dart index 1563711..5eb0123 100644 --- a/lib/features/dashboard/widgets/dashboard_map_panel.dart +++ b/lib/features/dashboard/widgets/dashboard_map_panel.dart @@ -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, + ), + ), + ], + ), + ), + ), + ], ), ); }, diff --git a/lib/features/dashboard/widgets/gps_status_indicator.dart b/lib/features/dashboard/widgets/gps_status_indicator.dart index 635e4d3..4e20cb4 100644 --- a/lib/features/dashboard/widgets/gps_status_indicator.dart +++ b/lib/features/dashboard/widgets/gps_status_indicator.dart @@ -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; diff --git a/lib/features/dashboard/widgets/speed_readout.dart b/lib/features/dashboard/widgets/speed_readout.dart index 79203a3..a6ff310 100644 --- a/lib/features/dashboard/widgets/speed_readout.dart +++ b/lib/features/dashboard/widgets/speed_readout.dart @@ -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, diff --git a/lib/features/performance/performance_timer_service.dart b/lib/features/performance/performance_timer_service.dart index 5682b0c..8c889a5 100644 --- a/lib/features/performance/performance_timer_service.dart +++ b/lib/features/performance/performance_timer_service.dart @@ -79,7 +79,15 @@ class PerformanceTimerService extends StateNotifier { if (sample.speed == null) return; // Reject corrupted or impossible samples if (sample.quality != null && sample.quality!.isRejected) return; - + + // Reject stale GPS samples from mutating performance runs or launching + if (!sample.isSpeedFresh) { + if (state.state == PerformanceState.timing) { + state = state.copyWith(state: PerformanceState.aborted); + } + return; + } + final speedMph = sample.speed!; if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) { diff --git a/lib/features/performance/services/performance_launch_state_machine.dart b/lib/features/performance/services/performance_launch_state_machine.dart index 8399d6f..b2fc2cb 100644 --- a/lib/features/performance/services/performance_launch_state_machine.dart +++ b/lib/features/performance/services/performance_launch_state_machine.dart @@ -130,6 +130,22 @@ class PerformanceLaunchStateMachine extends StateNotifier { // --------------------------------------------------------------------------- int? _latestObservedMonotonicMs; int? _lastGpsEvidenceMonotonicMs; + DateTime? _lastSeenGpsTimestamp; int? _lastImuEvidenceMonotonicMs; int? _lastDbCommitMonotonicMs; bool _dbPersistenceFailed = false; @@ -238,6 +239,7 @@ class RideStateMachine extends StateNotifier { void _resetHealthEvidence() { _latestObservedMonotonicMs = null; _lastGpsEvidenceMonotonicMs = null; + _lastSeenGpsTimestamp = null; _lastImuEvidenceMonotonicMs = null; _lastDbCommitMonotonicMs = null; _dbPersistenceFailed = false; @@ -266,8 +268,14 @@ class RideStateMachine extends StateNotifier { 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 { void reset() { state = RideSession(); + _resetHealthEvidence(); } void _onTelemetryUpdate(TelemetrySample sample) { @@ -1119,14 +1128,17 @@ class RideStateMachine extends StateNotifier { 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 { } // 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 { ); // 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 { ); } - if (sample.speed != null) { + if (sample.isSpeedFresh && sample.speed != null) { checkAndLogEvent('High Speed', sample.speed!, sample.speed! > 100.0); } } diff --git a/lib/models/telemetry_sample.dart b/lib/models/telemetry_sample.dart index e569603..dc72719 100644 --- a/lib/models/telemetry_sample.dart +++ b/lib/models/telemetry_sample.dart @@ -1,3 +1,4 @@ +import 'recording_health_status.dart'; import '../services/validation/telemetry_validator.dart'; class TelemetrySample { @@ -73,6 +74,67 @@ 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 null (no GPS fix available) + /// - [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 (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, diff --git a/lib/services/telemetry_service.dart b/lib/services/telemetry_service.dart index 283b420..0154eff 100644 --- a/lib/services/telemetry_service.dart +++ b/lib/services/telemetry_service.dart @@ -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; } diff --git a/test/gnss_metrics_integrity_test.dart b/test/gnss_metrics_integrity_test.dart new file mode 100644 index 0000000..02186dd --- /dev/null +++ b/test/gnss_metrics_integrity_test.dart @@ -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 speak(String text, {bool force = false}) async {} +} + +class FakeTelemetryService extends TelemetryService { + final StreamController _controller = + StreamController.broadcast(); + + FakeTelemetryService( + super.loc, + super.fusion, + super.compass, + super.logger, + super.validator, + ); + + @override + Stream get telemetryStream => _controller.stream; + + void emitSample(TelemetrySample sample) => _controller.add(sample); + + @override + void dispose() => _controller.close(); +} + +class _NullLocationService extends Fake implements LocationService { + @override + void Function(Position position, bool accepted, String? rejectionReason)? + onGpsDiagnostic; + @override + Stream getFilteredPositionStream() => const Stream.empty(); +} + +class _NullSensorFusionService extends Fake implements SensorFusionService { + @override + Stream get leanAngleStream => const Stream.empty(); + @override + Stream get pitchAngleStream => const Stream.empty(); + @override + Stream get gForceStream => const Stream.empty(); + @override + double get latestRollRateDegPerSec => 0.0; + @override + double get latestRawAccelX => 0.0; + @override + double get latestRawAccelY => 0.0; + @override + double get latestRawAccelZ => 0.0; + @override + double get latestRawGyroX => 0.0; + @override + double get latestRawGyroY => 0.0; + @override + double get latestRawGyroZ => 0.0; +} + +class _NullCompassService extends Fake implements CompassService { + @override + Stream get headingStream => const Stream.empty(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late 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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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 perfController; + late FakeMonotonicClock clock; + PerformanceLaunchStateMachine? perfSm; + + setUp(() { + perfController = StreamController.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.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.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.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.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.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.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.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.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 timerController; + PerformanceTimerService? timerSm; + + setUp(() { + timerController = StreamController.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.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.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.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); + }); + }); +} diff --git a/test/stale_gps_regression_test.dart b/test/stale_gps_regression_test.dart new file mode 100644 index 0000000..e56516d --- /dev/null +++ b/test/stale_gps_regression_test.dart @@ -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 speak(String text, {bool force = false}) async {} +} + +class FakeTelemetryService extends TelemetryService { + final StreamController _controller = + StreamController.broadcast(); + + FakeTelemetryService( + super.loc, + super.fusion, + super.compass, + super.logger, + super.validator, + ); + + @override + Stream get telemetryStream => _controller.stream; + + void emitSample(TelemetrySample sample) => _controller.add(sample); + + @override + void dispose() => _controller.close(); +} + +class _NullLocationService extends Fake implements LocationService { + @override + void Function(Position position, bool accepted, String? rejectionReason)? + onGpsDiagnostic; + @override + Stream getFilteredPositionStream() => const Stream.empty(); +} + +class _NullSensorFusionService extends Fake implements SensorFusionService { + @override + Stream get leanAngleStream => const Stream.empty(); + @override + Stream get pitchAngleStream => const Stream.empty(); + @override + Stream get gForceStream => const Stream.empty(); + @override + double get latestRollRateDegPerSec => 0.0; + @override + double get latestRawAccelX => 0.0; + @override + double get latestRawAccelY => 0.0; + @override + double get latestRawAccelZ => 0.0; + @override + double get latestRawGyroX => 0.0; + @override + double get latestRawGyroY => 0.0; + @override + double get latestRawGyroZ => 0.0; +} + +class _NullCompassService extends Fake implements CompassService { + @override + Stream get headingStream => const Stream.empty(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + 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 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.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'); + }); + }); +} diff --git a/test/truthful_heading_location_test.dart b/test/truthful_heading_location_test.dart new file mode 100644 index 0000000..d3f1ec4 --- /dev/null +++ b/test/truthful_heading_location_test.dart @@ -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 _controller = + StreamController.broadcast(); + + @override + void Function(Position position, bool accepted, String? rejectionReason)? + onGpsDiagnostic; + + @override + Stream getFilteredPositionStream() => _controller.stream; + + void emitPosition(Position position) => _controller.add(position); + + @override + void dispose() => _controller.close(); +} + +class FakeCompassService extends Fake implements CompassService { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get headingStream => _controller.stream; + + void emitHeading(double heading) => _controller.add(heading); + + @override + void dispose() => _controller.close(); +} + +class _NullSensorFusionService extends Fake implements SensorFusionService { + @override + Stream get leanAngleStream => const Stream.empty(); + @override + Stream get pitchAngleStream => const Stream.empty(); + @override + Stream get gForceStream => const Stream.empty(); + @override + double get latestRollRateDegPerSec => 0.0; + @override + double get latestRawAccelX => 0.0; + @override + double get latestRawAccelY => 0.0; + @override + double get latestRawAccelZ => 0.0; + @override + double get latestRawGyroX => 0.0; + @override + double get latestRawGyroY => 0.0; + @override + double get latestRawGyroZ => 0.0; +} + +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.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.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.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.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.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.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.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 testStreamController; + + setUp(() { + testStreamController = StreamController.broadcast(); + }); + + tearDown(() async { + await testStreamController.close(); + }); + + Widget buildTestWidget({required Stream stream}) { + return ProviderScope( + overrides: [ + telemetryStreamProvider.overrideWith((ref) => stream), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + ], + 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(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(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 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); + }); + }); +} diff --git a/test/truthful_speed_presentation_test.dart b/test/truthful_speed_presentation_test.dart new file mode 100644 index 0000000..ca979b6 --- /dev/null +++ b/test/truthful_speed_presentation_test.dart @@ -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 telemetryController; + + setUp(() { + telemetryController = StreamController.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); + }); + }); +}