Merge pull request #4 from bponders12-cell/fix/gnss-freshness-semantics
fix(telemetry): harden GNSS freshness semantics
This commit is contained in:
commit
2a8c438f07
|
|
@ -0,0 +1,106 @@
|
|||
# MOTORSIGNAL DEVELOPER OWNERSHIP MAP — PHASE 1
|
||||
|
||||
## A. Source State
|
||||
- Known Baseline Branch: `master`
|
||||
- Checkpoint Commit: `d3caf37bb2ff0031bec5982791b77127dd341cbd` (Merge pull request #3)
|
||||
- No tracked or staged modifications exist in production files.
|
||||
|
||||
## B. Data Flow (Sensor to Pixels)
|
||||
1. **Acquisition (`lib/services/location_service.dart`)**
|
||||
- `Geolocator.getPositionStream()` initializes the Android Fused Location Provider.
|
||||
- Settings: `LocationAccuracy.bestForNavigation` at a requested 100ms interval.
|
||||
- Triggers `_handleRawPosition()` upon receiving an OS-level GPS fix.
|
||||
|
||||
2. **Validation (`LocationService._isValidPosition`)**
|
||||
- Validates the fix before applying logic.
|
||||
- Drops fixes with > 200m accuracy (bootstrapping) or > 80m-100m accuracy (moving).
|
||||
- Drops impossible speeds (> 100.0 m/s or ~223 MPH).
|
||||
- Drops impossible acceleration (> 30.0 m/s²) and detects GPS teleportation over 50 meters.
|
||||
|
||||
3. **Filtering & Smoothing (`LocationService._applyEmaFilter`)**
|
||||
- **Stationary Jitter Deadband**: Speeds under 0.6 m/s (~1.3 MPH) are forced to 0.0 to prevent drifting at stoplights.
|
||||
- **Fast Dynamic Exponential Moving Average (EMA)**:
|
||||
- `alpha = 0.90` when delta > 0.4 m/s (preserves instant responsiveness when accelerating/braking).
|
||||
- `alpha = 0.75` for cruising.
|
||||
- The smoothed speed (in m/s) is added to `_controller.stream`.
|
||||
|
||||
4. **Sampling & The Primary Clock (`lib/services/telemetry_service.dart`)**
|
||||
- Subscribes to the location stream but **decouples** acquisition from reporting.
|
||||
- Uses a `Timer.periodic(const Duration(milliseconds: 50))` to establish a requested 20Hz monotonic clock.
|
||||
- `_emitSample()` reads the latest filtered position, converts speed from m/s to MPH (`speed * 2.23694`), and emits a `TelemetrySample`.
|
||||
|
||||
5. **State Aggregation (`lib/features/rides/ride_state_machine.dart`)**
|
||||
- Consumes `TelemetrySample`s at 20Hz via `_onTelemetryUpdate`.
|
||||
- Modifies dynamic metric tracking (`_processStats`) and performs auto-starts (gate: `>= 1.5 MPH`).
|
||||
- Isolates motorcycle dynamics (Top Speed, Lean Angle, G-Force) behind a `>= 3.0 MPH` gate to prevent kickstand or walking artifacts from registering as personal records.
|
||||
|
||||
6. **UI Display (`lib/features/dashboard/widgets/speed_readout.dart`)**
|
||||
- Rebuilding the entire dashboard at 20Hz would cause severe UI lag.
|
||||
- `ResponsiveDashboardLayout` isolates updates using the `SpeedReadout` leaf widget.
|
||||
- `SpeedReadout` selects freshness-aware `sample.freshSpeed` from `telemetryStreamProvider`; unavailable/stale speed renders as `--`.
|
||||
- Clamps the value to `>= 0.0`, converts to an integer string (`toStringAsFixed(0)`), and renders.
|
||||
|
||||
## C. Discoveries (Current implementation speeds)
|
||||
MotorSignal manages speed in several distinct concepts:
|
||||
- **Raw GNSS Speed**: `position.speed` in `LocationService` (m/s directly from OS, unfiltered).
|
||||
- **Filtered Speed**: `_emaSpeed` in `LocationService` (m/s after EMA and deadband).
|
||||
- **Telemetry Speed**: `sample.speed` in `TelemetrySample` (converted to MPH, sampled at 20Hz).
|
||||
- **Display Speed**: Clamped to `>= 0.0` inside `SpeedReadout` before integer rounding.
|
||||
- **Top Speed (Current implementation)**: `state.topSpeedMph` managed by `RideStateMachine`, rejecting single spikes > 220 MPH.
|
||||
- **Average Speed**: Derived in `RideStateMachine` (`(tripDistanceMiles / newMovingTime) * 3600`) — relies heavily on the `1.8 MPH` threshold determining `movingTime`.
|
||||
|
||||
## D. Boundaries (High-Risk Concepts)
|
||||
The following behaviors are load-bearing and architecturally high-risk:
|
||||
1. **The 20Hz / 50ms Clock**: `TelemetryService._samplingTimer` is the primary telemetry sampling clock. The UI does not poll for location or dictate sampling frequency.
|
||||
2. **Stationary Deadband (< 0.6 m/s)**: Enforced in `LocationService` to prevent the speedometer from bouncing while idling.
|
||||
3. **Dynamic Metrics Gate (< 3.0 MPH)**: `RideStateMachine` ignores lean angles and heavy G-forces when rolling slowly.
|
||||
4. **Auto-Start Gate (>= 1.5 MPH)**: `RideStateMachine` uses this threshold to transition from `ready` to active recording.
|
||||
5. **GPS Freshness Invariant**: GPS freshness advances from genuinely newer underlying GNSS evidence, not merely from creation of a new telemetry wrapper. Repeated or cached GNSS fixes repackaged into 20 Hz `TelemetrySample`s do not refresh `_lastGpsEvidenceMonotonicMs`.
|
||||
6. **Speed Freshness Invariant**: Fresh telemetry wrapper does not imply fresh GNSS-derived speed. Speed freshness (`TelemetrySample.isSpeedFresh` / `TelemetrySample.freshSpeed`) is valid only while the underlying GNSS fix age is within the canonical 4000 ms staleness bound. Stale GNSS fixes render as `'--'` on `SpeedReadout` rather than freezing on the last measured speed or fabricating 0 MPH.
|
||||
7. **GNSS-Derived Metrics Integrity Invariant**: Stale GNSS evidence may remain preserved in raw telemetry (`sample.speed`), but it must not create new GNSS-derived ride facts:
|
||||
- **Top Speed**: `state.topSpeedMph` updates only when `sample.isSpeedFresh && sample.speed != null && sample.speed! <= 220.0`.
|
||||
- **Moving Time & Stopped Time**: Sub-second continuous time accumulation (`_movingTimeAccumulator` / `_stoppedTimeAccumulator`) requires `sample.isSpeedFresh && _lastSample!.isSpeedFresh`. Stale samples do not accumulate moving or stopped time.
|
||||
- **Dynamic Motorcycle Metrics Gate**: Dynamic metrics (max lean, max accel/braking G) require `sample.isSpeedFresh && sample.speed != null && sample.speed! >= 3.0`, preventing stationary/kickstand artifacts during a GPS outage from polluting records.
|
||||
- **Trip Distance & Elevation**: Requires `hasFreshGps = sample.isSpeedFresh && (_lastSample?.isSpeedFresh ?? false)`. Distance and elevation do not accumulate across an outage, preventing artificial catch-up jumps upon recovery.
|
||||
- **High Speed Event Detection**: Logging 'High Speed' requires `sample.isSpeedFresh && sample.speed != null && sample.speed! > 100.0`, preventing repeated events during an outage.
|
||||
- **Performance Launch State Machine**: Active runs (`launched` or `countdown`) abort with `PerformanceAbortReason.gpsLost` when GPS becomes stale. `ready` state reverts to `idle` on stale GPS, preventing false movement detection or high-speed launch anomalies upon recovery.
|
||||
- **Performance Timer Service**: Active runs in `timing` state abort when GPS becomes stale, preventing false 0-60/0-100 completions across outages.
|
||||
8. **Heading & Location Freshness Invariant**:
|
||||
- **Heading Selection**: `TelemetryService._emitSample()` selects GPS course (`_latestPosition.heading`) only when GPS is fresh (`isGpsFresh`), `_latestPosition.heading >= 0`, and `speedMph > 3.0`. When GPS becomes stale (> 4000 ms), heading selection immediately falls back to live hardware compass azimuth (`_latestCompassHeading`), preventing stale course angles from overriding the rider's actual orientation. When fresh GPS resumes and speed exceeds 3.0 MPH, GPS heading automatically recovers. Normal 10 Hz / 20 Hz sample repetitions within 4000 ms maintain heading stability without flicker.
|
||||
- **Location & Map Presentation**: `DashboardMapPanel` consumes `s.value?.isGpsFresh`. When GPS coordinates are fresh, the vehicle marker renders in active CyberCyan without warning overlays. When GPS becomes stale (> 4000 ms), the last-known coordinate is retained without artificial dead-reckoning or fake drift, the vehicle marker is dimmed (alpha 0.38), and a subtle `LAST KNOWN LOCATION` pill badge is displayed. When fresh GPS recovers, the badge disappears and the marker restores full CyberCyan.
|
||||
- **GPS Status Indicator**: `GPSStatusIndicator` evaluates `isGpsFresh`. When GPS is stale, it truthfully indicates `NO GPS` with `Icons.gps_off` rather than falsely reporting `GPS 3D FIX` from cached accuracy.
|
||||
|
||||
## E. Tests
|
||||
- `test/location_service_test.dart`:
|
||||
- Protects physical/GPS thresholds (0.6 m/s deadband == 1.34 MPH, 100 m/s limit == 223 MPH, 3.0s 0-60 == 0.91g).
|
||||
- `test/telemetry_validation_test.dart`:
|
||||
- Validates `TelemetryValidator`'s sanity checks, such as rejecting > 220 MPH jumps and flagging poor GPS confidence.
|
||||
- `test/stale_gps_regression_test.dart`:
|
||||
- Validates GPS evidence freshness semantics: duplicate fix timestamps do not refresh GPS freshness, genuinely newer fixes refresh evidence, older out-of-order fixes are ignored, null fixes do not refresh evidence, normal 10 Hz / 20 Hz sampling remains healthy, GPS outages degrade health to `recordingDegraded`, and fresh fixes recover to `recordingActive`.
|
||||
- `test/truthful_speed_presentation_test.dart`:
|
||||
- Validates speed freshness semantics on `TelemetrySample` and `SpeedReadout` presentation across normal moving GPS, 10 Hz / 20 Hz duplication, GNSS outages, non-zero placeholder behavior (`'--'`), genuine stopped motorcycle (`0` MPH), automatic recovery, and ride-independent operation.
|
||||
- `test/gnss_metrics_integrity_test.dart`:
|
||||
- Validates GNSS-derived metrics integrity during stale GPS: moving time cessation, distance cessation, top speed isolation, boundary conditions at 3999ms / 4000ms / 4001ms, recovery without jump distance or time integration across outage, dynamic motorcycle metrics isolation, High Speed event deduplication, PerformanceLaunchStateMachine abort on stale GPS in launched/countdown and reversion in ready, and PerformanceTimerService abort in timing.
|
||||
- `test/truthful_heading_location_test.dart`:
|
||||
- Validates heading selection and map/location presentation truthfulness during stale GPS: GPS course priority when moving fresh, normal 10 Hz / 20 Hz fix stability, fallback to live compass when GPS is stale (> 4000ms), automatic heading recovery on fresh GPS, 3999/4000/4001ms boundaries, map marker dimming, LAST KNOWN LOCATION pill badge, absence of fake drift / dead reckoning, automatic map recovery, ride independence, and raw forensic data preservation.
|
||||
|
||||
## F. Documents Created
|
||||
- `docs/CODE_MAP.md`
|
||||
- `docs/DEBUGGING_MAP.md`
|
||||
|
||||
## G. Change Scope
|
||||
- `lib/features/rides/ride_state_machine.dart`: Added `_lastSeenGpsTimestamp` tracking so GPS evidence freshness advances only on genuinely newer GNSS fixes. Aligned lifecycle reset in `_resetHealthEvidence()` and `reset()`. Gated `newTopSpeed`, `isDynamicallyMoving`, continuous moving/stopped time accumulation, distance accumulation, elevation accumulation, and 'High Speed' event detection on `sample.isSpeedFresh`.
|
||||
- `lib/models/telemetry_sample.dart`: Added `isSpeedFresh`, `freshSpeed`, and `isGpsFresh` getters reusing `RecordingHealthContract.evidenceStaleAfterMs` (4000 ms).
|
||||
- `lib/features/dashboard/widgets/speed_readout.dart`: Updated `SpeedReadout` to present `freshSpeed` and render `'--'` when stale.
|
||||
- `lib/features/performance/services/performance_launch_state_machine.dart`: Added stale GPS check in `_onTelemetryUpdate` to abort active runs (`launched`, `countdown`) with `PerformanceAbortReason.gpsLost` and revert `ready` to `idle`.
|
||||
- `lib/features/performance/performance_timer_service.dart`: Added stale GPS check in `_onTelemetryUpdate` to abort `timing` state on stale GPS.
|
||||
- `lib/services/telemetry_service.dart`: Gated GPS heading selection on `isGpsFresh` using `RecordingHealthContract.evidenceStaleAfterMs` (4000 ms).
|
||||
- `lib/features/dashboard/widgets/dashboard_map_panel.dart`: Dimmed vehicle marker and added `LAST KNOWN LOCATION` pill badge when `!isGpsFresh`.
|
||||
- `lib/features/dashboard/widgets/gps_status_indicator.dart`: Required `isGpsFresh` for reporting active GPS fix status.
|
||||
- `test/stale_gps_regression_test.dart`: Added comprehensive behavioral test suite for stale GPS, duplicate fixes, outage degradation, and recovery.
|
||||
- `test/truthful_speed_presentation_test.dart`: Added comprehensive behavioral and widget test suite for truthful speed presentation.
|
||||
- `test/gnss_metrics_integrity_test.dart`: Added comprehensive behavioral test suite for GNSS-derived metrics integrity during stale GPS.
|
||||
- `test/truthful_heading_location_test.dart`: Added comprehensive behavioral and widget test suite for truthful heading selection and location presentation.
|
||||
|
||||
## H. Remaining Issues / Future Considerations
|
||||
- All primary GNSS-derived subsystems (recording health, speedometer presentation, ride statistics, performance launch timers, heading selection, map/location presentation, and GPS status indicator) now operate under a single, unified canonical freshness contract (4000 ms bound). No further unaddressed stale GNSS corruption vectors remain in core telemetry paths.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,19 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
|
||||
void _onTelemetryUpdate(TelemetrySample sample) {
|
||||
if (sample.speed == null) return;
|
||||
// Reject stale GPS samples from mutating performance runs or launching
|
||||
if (!sample.isSpeedFresh) {
|
||||
// A recovered moving fix cannot establish when the launch occurred.
|
||||
_lastSample = null;
|
||||
if (state.state == PerformanceState.ready) {
|
||||
state = state.copyWith(state: PerformanceState.idle);
|
||||
}
|
||||
if (state.state == PerformanceState.timing) {
|
||||
state = state.copyWith(state: PerformanceState.aborted);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject corrupted or impossible samples
|
||||
if (sample.quality != null && sample.quality!.isRejected) return;
|
||||
|
||||
|
|
@ -87,7 +100,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
|||
if (state.state != PerformanceState.ready) {
|
||||
state = state.copyWith(state: PerformanceState.ready);
|
||||
}
|
||||
} else if (speedMph >= _speedThresholdMph) {
|
||||
} else if (speedMph >= _speedThresholdMph &&
|
||||
state.state == PerformanceState.ready) {
|
||||
// Start timing immediately when motorcycle launches past 1.0 MPH
|
||||
// Interpolate the exact start time between the last frame (stopped) and this frame
|
||||
_startTime = _interpolateTime(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,22 @@ class PerformanceLaunchStateMachine extends StateNotifier<PerformanceLaunchAttem
|
|||
return;
|
||||
}
|
||||
|
||||
// 1b. Reject stale GPS samples from mutating performance runs or launching
|
||||
if (!sample.isSpeedFresh) {
|
||||
if (state.state == PerformanceLaunchState.launched ||
|
||||
state.state == PerformanceLaunchState.countdown) {
|
||||
state = state.copyWith(
|
||||
state: PerformanceLaunchState.aborted,
|
||||
abortReason: PerformanceAbortReason.gpsLost,
|
||||
);
|
||||
} else if (state.state == PerformanceLaunchState.ready) {
|
||||
state = state.copyWith(
|
||||
state: PerformanceLaunchState.idle,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Monotonic sequence sanity check (prevent stale/out-of-order samples from corrupting timing)
|
||||
final sampleMonotonicMs = sample.monotonicTimestampMs ?? clock.elapsedMilliseconds;
|
||||
final sampleSeq = sample.sequenceNumber ?? 0;
|
||||
|
|
|
|||
|
|
@ -225,6 +225,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
// ---------------------------------------------------------------------------
|
||||
int? _latestObservedMonotonicMs;
|
||||
int? _lastGpsEvidenceMonotonicMs;
|
||||
DateTime? _lastSeenGpsTimestamp;
|
||||
int? _lastImuEvidenceMonotonicMs;
|
||||
int? _lastDbCommitMonotonicMs;
|
||||
bool _dbPersistenceFailed = false;
|
||||
|
|
@ -238,6 +239,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
void _resetHealthEvidence() {
|
||||
_latestObservedMonotonicMs = null;
|
||||
_lastGpsEvidenceMonotonicMs = null;
|
||||
_lastSeenGpsTimestamp = null;
|
||||
_lastImuEvidenceMonotonicMs = null;
|
||||
_lastDbCommitMonotonicMs = null;
|
||||
_dbPersistenceFailed = false;
|
||||
|
|
@ -266,8 +268,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
if (latest == null || monotonicMs > latest) {
|
||||
_latestObservedMonotonicMs = monotonicMs;
|
||||
}
|
||||
// Real GPS fix evidence: the fix timestamp is present.
|
||||
if (sample.gpsTimestamp != null) _lastGpsEvidenceMonotonicMs = monotonicMs;
|
||||
// Real GPS fix evidence: genuinely newer fix timestamp is present.
|
||||
if (sample.gpsTimestamp != null) {
|
||||
final gpsTime = sample.gpsTimestamp!;
|
||||
if (_lastSeenGpsTimestamp == null || gpsTime.isAfter(_lastSeenGpsTimestamp!)) {
|
||||
_lastSeenGpsTimestamp = gpsTime;
|
||||
_lastGpsEvidenceMonotonicMs = monotonicMs;
|
||||
}
|
||||
}
|
||||
// Real IMU sample evidence: the IMU timestamp is present.
|
||||
if (sample.imuTimestamp != null) _lastImuEvidenceMonotonicMs = monotonicMs;
|
||||
_lastAcceptedTelemetryTime = DateTime.now();
|
||||
|
|
@ -725,6 +733,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
|
||||
void reset() {
|
||||
state = RideSession();
|
||||
_resetHealthEvidence();
|
||||
}
|
||||
|
||||
void _onTelemetryUpdate(TelemetrySample sample) {
|
||||
|
|
@ -1119,14 +1128,17 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
double newElevationGain = state.elevationGain;
|
||||
double newElevationLoss = state.elevationLoss;
|
||||
|
||||
if (sample.speed != null && sample.speed! <= 220.0) {
|
||||
if (sample.isSpeedFresh && sample.speed != null && sample.speed! <= 220.0) {
|
||||
if (sample.speed! > state.topSpeedMph) {
|
||||
newTopSpeed = sample.speed!;
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous sub-second time accumulation across 20Hz frames
|
||||
if (_lastSample != null && sample.speed != null) {
|
||||
if (_lastSample != null &&
|
||||
sample.speed != null &&
|
||||
sample.isSpeedFresh &&
|
||||
_lastSample!.isSpeedFresh) {
|
||||
final dt =
|
||||
sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds /
|
||||
1000.0;
|
||||
|
|
@ -1142,7 +1154,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
}
|
||||
|
||||
// Speed-gate dynamic motorcycle metrics (require speed >= 3.0 MPH to prevent stationary kickstand / dismount artifacts from registering as personal riding records)
|
||||
final isDynamicallyMoving = sample.speed != null && sample.speed! >= 3.0;
|
||||
final isDynamicallyMoving =
|
||||
sample.isSpeedFresh && sample.speed != null && sample.speed! >= 3.0;
|
||||
|
||||
if (isDynamicallyMoving &&
|
||||
sample.leanAngle != null &&
|
||||
|
|
@ -1180,17 +1193,19 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
);
|
||||
|
||||
// Accumulate distance ONLY if moving and GPS accuracy is reasonable (suppresses stationary jitter at stop lights)
|
||||
final hasFreshGps =
|
||||
sample.isSpeedFresh && (_lastSample?.isSpeedFresh ?? false);
|
||||
final isMoving =
|
||||
(sample.speed != null && sample.speed! >= 1.0) ||
|
||||
(_lastSample!.speed != null && _lastSample!.speed! >= 1.0);
|
||||
final hasGoodGps =
|
||||
sample.gpsAccuracy != null && sample.gpsAccuracy! <= 35.0;
|
||||
if (isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
|
||||
if (hasFreshGps && isMoving && hasGoodGps && distMeters > 0.3 && distMeters < 150.0) {
|
||||
_accumulatedDistance += distMeters;
|
||||
}
|
||||
|
||||
// Elevation tracking
|
||||
if (sample.altitude != null && _lastSample!.altitude != null) {
|
||||
if (hasFreshGps && sample.altitude != null && _lastSample!.altitude != null) {
|
||||
final elevationDelta = sample.altitude! - _lastSample!.altitude!;
|
||||
// Threshold filter to prevent phantom gain (e.g. > 1 meter change)
|
||||
if (elevationDelta.abs() > 1.0 && elevationDelta.abs() < 50.0) {
|
||||
|
|
@ -1277,7 +1292,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
|||
);
|
||||
}
|
||||
|
||||
if (sample.speed != null) {
|
||||
if (sample.isSpeedFresh && sample.speed != null) {
|
||||
checkAndLogEvent('High Speed', sample.speed!, sample.speed! > 100.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'recording_health_status.dart';
|
||||
import '../services/validation/telemetry_validator.dart';
|
||||
|
||||
class TelemetrySample {
|
||||
|
|
@ -73,6 +74,72 @@ class TelemetrySample {
|
|||
this.quality,
|
||||
});
|
||||
|
||||
/// Whether the encapsulated speed is fresh according to the canonical
|
||||
/// evidence staleness bound (4000 ms).
|
||||
///
|
||||
/// Returns false when:
|
||||
/// - [speed] is null (no speed measurement available)
|
||||
/// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms)
|
||||
/// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp]
|
||||
///
|
||||
/// Returns true for:
|
||||
/// - Genuinely advancing GPS fixes with valid speed
|
||||
/// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound
|
||||
/// - Fresh 0.0 MPH stopped fixes
|
||||
/// - Synthetic test samples where GPS metadata was not explicitly attached
|
||||
bool get isSpeedFresh {
|
||||
if (speed == null) return false;
|
||||
if (gpsAgeMs != null) {
|
||||
return gpsAgeMs! >= 0 &&
|
||||
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
|
||||
}
|
||||
if (gpsTimestamp != null) {
|
||||
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
|
||||
return age >= 0 &&
|
||||
age <= RecordingHealthContract.evidenceStaleAfterMs;
|
||||
}
|
||||
// Synthetic test samples without GPS age/timestamp retain speed validity.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Whether the encapsulated GNSS evidence (coordinates, heading, accuracy) is
|
||||
/// fresh according to the canonical evidence staleness bound (4000 ms).
|
||||
///
|
||||
/// Returns false when:
|
||||
/// - [latitude] or [longitude] is absent, non-finite, or out of range
|
||||
/// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms)
|
||||
/// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp]
|
||||
///
|
||||
/// Returns true for:
|
||||
/// - Genuinely advancing GPS fixes with valid coordinates
|
||||
/// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound
|
||||
/// - Synthetic test samples where GPS metadata was not explicitly attached
|
||||
bool get isGpsFresh {
|
||||
if (latitude == null || longitude == null ||
|
||||
!latitude!.isFinite || !longitude!.isFinite ||
|
||||
latitude!.abs() > 90 || longitude!.abs() > 180) {
|
||||
return false;
|
||||
}
|
||||
if (gpsAgeMs != null) {
|
||||
return gpsAgeMs! >= 0 &&
|
||||
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;
|
||||
}
|
||||
if (gpsTimestamp != null) {
|
||||
final age = timestamp.difference(gpsTimestamp!).inMilliseconds;
|
||||
return age >= 0 &&
|
||||
age <= RecordingHealthContract.evidenceStaleAfterMs;
|
||||
}
|
||||
// Synthetic test samples without GPS age/timestamp retain validity.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Current trustworthy speed in MPH, or null if GPS evidence is absent or stale.
|
||||
///
|
||||
/// Prevents stale cached GNSS fixes from presenting old speeds indefinitely
|
||||
/// while keeping genuine fresh 0.0 MPH measurements distinguishable from
|
||||
/// unavailable/stale speed.
|
||||
double? get freshSpeed => isSpeedFresh ? speed : null;
|
||||
|
||||
TelemetrySample copyWith({
|
||||
DateTime? timestamp,
|
||||
int? sequenceNumber,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ void main() {
|
|||
leanAngle: 24.5,
|
||||
longitudinalG: 0.42,
|
||||
lateralG: 0.35,
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
gpsAccuracy: 9.0,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,712 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/features/performance/models/performance_launch_models.dart';
|
||||
import 'package:motorcycle_dash/features/performance/services/monotonic_clock.dart';
|
||||
import 'package:motorcycle_dash/features/performance/services/performance_launch_state_machine.dart';
|
||||
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/services/location_service.dart';
|
||||
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
||||
import 'package:motorcycle_dash/services/compass_service.dart';
|
||||
import 'package:motorcycle_dash/services/audio_alert_service.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart'
|
||||
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
|
||||
import 'package:drift/native.dart';
|
||||
|
||||
class MockAudioAlertService extends Fake implements AudioAlertService {
|
||||
@override
|
||||
Future<void> speak(String text, {bool force = false}) async {}
|
||||
}
|
||||
|
||||
class FakeTelemetryService extends TelemetryService {
|
||||
final StreamController<TelemetrySample> _controller =
|
||||
StreamController<TelemetrySample>.broadcast();
|
||||
|
||||
FakeTelemetryService(
|
||||
super.loc,
|
||||
super.fusion,
|
||||
super.compass,
|
||||
super.logger,
|
||||
super.validator,
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<TelemetrySample> get telemetryStream => _controller.stream;
|
||||
|
||||
void emitSample(TelemetrySample sample) => _controller.add(sample);
|
||||
|
||||
@override
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class _NullLocationService extends Fake implements LocationService {
|
||||
@override
|
||||
void Function(Position position, bool accepted, String? rejectionReason)?
|
||||
onGpsDiagnostic;
|
||||
@override
|
||||
Stream<Position> getFilteredPositionStream() => const Stream.empty();
|
||||
}
|
||||
|
||||
class _NullSensorFusionService extends Fake implements SensorFusionService {
|
||||
@override
|
||||
Stream<double> get leanAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<double> get pitchAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<GForceData> get gForceStream => const Stream.empty();
|
||||
@override
|
||||
double get latestRollRateDegPerSec => 0.0;
|
||||
@override
|
||||
double get latestRawAccelX => 0.0;
|
||||
@override
|
||||
double get latestRawAccelY => 0.0;
|
||||
@override
|
||||
double get latestRawAccelZ => 0.0;
|
||||
@override
|
||||
double get latestRawGyroX => 0.0;
|
||||
@override
|
||||
double get latestRawGyroY => 0.0;
|
||||
@override
|
||||
double get latestRawGyroZ => 0.0;
|
||||
}
|
||||
|
||||
class _NullCompassService extends Fake implements CompassService {
|
||||
@override
|
||||
Stream<double> get headingStream => const Stream.empty();
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AppDatabase db;
|
||||
late FieldTestLogger logger;
|
||||
late FakeTelemetryService fakeTelemetry;
|
||||
late MockAudioAlertService mockAudio;
|
||||
late TelemetryValidator validator;
|
||||
late SharedPreferences prefs;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
logger = FieldTestLogger(db);
|
||||
validator = TelemetryValidator();
|
||||
fakeTelemetry = FakeTelemetryService(
|
||||
_NullLocationService(),
|
||||
_NullSensorFusionService(),
|
||||
_NullCompassService(),
|
||||
logger,
|
||||
validator,
|
||||
);
|
||||
mockAudio = MockAudioAlertService();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
fakeTelemetry.dispose();
|
||||
logger.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
ProviderContainer buildContainer() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
|
||||
audioAlertServiceProvider.overrideWithValue(mockAudio),
|
||||
fieldTestLoggerProvider.overrideWithValue(logger),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
|
||||
|
||||
TelemetrySample createSample({
|
||||
required int monotonicMs,
|
||||
DateTime? gpsTimestamp,
|
||||
int? gpsAgeMs,
|
||||
DateTime? imuTimestamp,
|
||||
double speed = 35.0,
|
||||
double lat = 34.0,
|
||||
double lon = -85.0,
|
||||
double? altitude,
|
||||
double? leanAngle,
|
||||
double? longitudinalG,
|
||||
double? gpsAccuracy = 10.0,
|
||||
}) {
|
||||
return TelemetrySample(
|
||||
timestamp: baseTime.add(Duration(milliseconds: monotonicMs)),
|
||||
monotonicTimestampMs: monotonicMs,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitude: altitude,
|
||||
speed: speed,
|
||||
gpsTimestamp: gpsTimestamp,
|
||||
gpsAgeMs: gpsAgeMs,
|
||||
imuTimestamp:
|
||||
imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)),
|
||||
leanAngle: leanAngle ?? 0.0,
|
||||
longitudinalG: longitudinalG ?? 0.0,
|
||||
gpsAccuracy: gpsAccuracy,
|
||||
quality: const TelemetryQuality(
|
||||
status: ValidationStatus.valid,
|
||||
overallConfidence: 1.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('Phase 1F — GNSS-Derived Metrics Integrity During Stale GPS', () {
|
||||
test('1. Moving vehicle enters GPS outage: moving time stops accumulating after stale boundary', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
final gpsFixTime = baseTime;
|
||||
|
||||
// 100 samples at 20 Hz (5 seconds). First 4s are fresh, last 1s is stale (> 4000ms).
|
||||
for (int i = 0; i <= 100; i++) {
|
||||
final monoMs = i * 50;
|
||||
final sampleTime = baseTime.add(Duration(milliseconds: monoMs));
|
||||
final ageMs = sampleTime.difference(gpsFixTime).inMilliseconds;
|
||||
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: monoMs,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: ageMs,
|
||||
speed: 40.0,
|
||||
));
|
||||
}
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final state = container.read(rideStateMachineProvider);
|
||||
// Fresh duration was 4.0s (80 frames of 50ms). Stale frames after 4000ms should NOT accumulate.
|
||||
expect(state.movingTimeSeconds, inInclusiveRange(3, 4));
|
||||
// Moving time must NOT have reached 5 seconds
|
||||
expect(state.movingTimeSeconds, lessThan(5));
|
||||
});
|
||||
|
||||
test('2. Moving vehicle enters GPS outage: trip distance stops accumulating', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
final gpsFixTime = baseTime;
|
||||
|
||||
// First sample
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 0,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 0,
|
||||
speed: 40.0,
|
||||
lat: 34.0000,
|
||||
lon: -85.0000,
|
||||
));
|
||||
|
||||
// Advance 1 second with advancing coordinates while fresh
|
||||
for (int i = 1; i <= 20; i++) {
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: i * 50,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: i * 50,
|
||||
speed: 40.0,
|
||||
lat: 34.0000 + (i * 0.0001),
|
||||
lon: -85.0000,
|
||||
));
|
||||
}
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceFresh = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
expect(distanceFresh, greaterThan(0.0));
|
||||
|
||||
// Now GPS fix stops advancing. We enter stale period (> 4000ms).
|
||||
// Even if coordinates somehow changed in a stale sample, distance must NOT accumulate.
|
||||
for (int i = 81; i <= 120; i++) {
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: i * 50,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: i * 50, // 4050ms to 6000ms -> STALE
|
||||
speed: 40.0,
|
||||
lat: 34.0000 + (i * 0.0001),
|
||||
lon: -85.0000,
|
||||
));
|
||||
}
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceAfterStale = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
expect(distanceAfterStale, equals(distanceFresh));
|
||||
});
|
||||
|
||||
test('3. Moving vehicle enters GPS outage: top speed does not update on stale samples', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
// Fresh sample at 50 MPH
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 0,
|
||||
speed: 50.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0);
|
||||
|
||||
// Stale sample with higher speed (85 MPH) but stale GPS (> 4000ms)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 6000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 5000, // 5000ms > 4000ms -> STALE
|
||||
speed: 85.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
// Top speed must remain 50.0 MPH, NOT 85.0 MPH
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 50.0);
|
||||
});
|
||||
|
||||
test('4. Boundary condition at 3999ms: sample is fresh, metrics accumulate', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
final gpsFixTime = baseTime;
|
||||
|
||||
// Sample at 3950ms
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 3950,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 3950,
|
||||
speed: 45.0,
|
||||
));
|
||||
|
||||
// Sample at 3999ms (age 3999ms <= 4000ms -> FRESH)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 3999,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 3999,
|
||||
speed: 65.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 65.0);
|
||||
});
|
||||
|
||||
test('5. Boundary condition at 4000ms: sample is fresh, metrics accumulate', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
final gpsFixTime = baseTime;
|
||||
|
||||
// Sample at 4000ms (age exactly 4000ms -> FRESH)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 4000,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 4000,
|
||||
speed: 70.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 70.0);
|
||||
});
|
||||
|
||||
test('6. Boundary condition at 4001ms: sample is stale, metrics do NOT accumulate', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
final gpsFixTime = baseTime;
|
||||
|
||||
// Baseline sample at 4000ms
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 4000,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 4000,
|
||||
speed: 55.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0);
|
||||
|
||||
// Boundary sample at 4001ms (age 4001ms > 4000ms -> STALE)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 4001,
|
||||
gpsTimestamp: gpsFixTime,
|
||||
gpsAgeMs: 4001,
|
||||
speed: 95.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
// Stale speed must NOT update top speed
|
||||
expect(container.read(rideStateMachineProvider).topSpeedMph, 55.0);
|
||||
});
|
||||
|
||||
test('7. GNSS recovery after outage: distance does NOT jump across outage', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
// Fix 1 at t=0
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
speed: 30.0,
|
||||
lat: 34.0000,
|
||||
lon: -85.0000,
|
||||
));
|
||||
|
||||
// Fix 1 frame at t=50ms
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 50,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
speed: 30.0,
|
||||
lat: 34.0001,
|
||||
lon: -85.0000,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceBeforeOutage = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
expect(distanceBeforeOutage, greaterThan(0.0));
|
||||
|
||||
// Outage: 10 seconds of stale samples at old coordinate
|
||||
for (int i = 81; i <= 200; i++) {
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: i * 50,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: i * 50, // 4050ms to 10000ms -> STALE
|
||||
speed: 30.0,
|
||||
lat: 34.0001,
|
||||
lon: -85.0000,
|
||||
));
|
||||
}
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceDuringOutage = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
expect(distanceDuringOutage, equals(distanceBeforeOutage));
|
||||
|
||||
// RECOVERY: Frame N (first fresh fix 100m away)
|
||||
final recoveryFixTime = baseTime.add(const Duration(milliseconds: 10050));
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 10050,
|
||||
gpsTimestamp: recoveryFixTime,
|
||||
gpsAgeMs: 0,
|
||||
speed: 30.0,
|
||||
lat: 34.0010, // ~100m jump
|
||||
lon: -85.0000,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceAtRecoveryFrame = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
// Recovery frame MUST NOT integrate the 100m gap across the outage!
|
||||
expect(distanceAtRecoveryFrame, equals(distanceBeforeOutage));
|
||||
|
||||
// Frame N+1: Next fresh sample 50ms later (real motion from recovery coordinate)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 10100,
|
||||
gpsTimestamp: recoveryFixTime.add(const Duration(milliseconds: 50)),
|
||||
gpsAgeMs: 0,
|
||||
speed: 30.0,
|
||||
lat: 34.00101, // ~1m move
|
||||
lon: -85.0000,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
final distanceAfterRecovery = container.read(rideStateMachineProvider).tripDistanceMiles;
|
||||
// Normal accumulation resumes without the artificial 100m catch-up jump!
|
||||
expect(distanceAfterRecovery, greaterThan(distanceBeforeOutage));
|
||||
// Change should be tiny (~1 meter), NOT ~100 meters
|
||||
final deltaMiles = distanceAfterRecovery - distanceBeforeOutage;
|
||||
expect(deltaMiles, lessThan(0.01)); // 0.01 miles is 16 meters
|
||||
});
|
||||
|
||||
test('8. Dynamic motorcycle metrics: cannot update max lean / G during GPS outage', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
// Fresh sample while riding normally
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 0,
|
||||
speed: 30.0,
|
||||
leanAngle: 15.0,
|
||||
longitudinalG: 0.2,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0);
|
||||
expect(container.read(rideStateMachineProvider).maxAccelG, 0.2);
|
||||
|
||||
// Bike enters garage / tunnel: GPS fix stops.
|
||||
// Stale sample (> 4000ms) with cached speed (30.0 MPH) and extreme kickstand lean (45 deg)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 6000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 5000,
|
||||
speed: 30.0, // cached raw speed
|
||||
leanAngle: 45.0, // kickstand lean
|
||||
longitudinalG: 1.5, // dropped phone / shake
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
// Stale speed must NOT satisfy dynamic moving gate; records must remain unchanged
|
||||
expect(container.read(rideStateMachineProvider).maxLeanRight, 15.0);
|
||||
expect(container.read(rideStateMachineProvider).maxAccelG, 0.2);
|
||||
});
|
||||
|
||||
test('9. High Speed event: not triggered repeatedly during GPS outage', () async {
|
||||
final container = buildContainer();
|
||||
final sm = container.read(rideStateMachineProvider.notifier);
|
||||
await sm.startRide();
|
||||
|
||||
// Fresh sample at 105 MPH -> triggers High Speed event
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 0,
|
||||
speed: 105.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
final eventsInitial = await db.select(db.rideEvents).get();
|
||||
expect(eventsInitial, hasLength(1));
|
||||
expect(eventsInitial.first.eventType, 'High Speed');
|
||||
|
||||
// 10 seconds later, GPS is stale (> 4000ms), cached speed still 105 MPH
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 11000,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 10000,
|
||||
speed: 105.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
final eventsAfterStale = await db.select(db.rideEvents).get();
|
||||
// Must NOT add another High Speed event from stale speed!
|
||||
expect(eventsAfterStale, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 1F — PerformanceLaunchStateMachine Stale GPS Gating', () {
|
||||
late StreamController<TelemetrySample> perfController;
|
||||
late FakeMonotonicClock clock;
|
||||
PerformanceLaunchStateMachine? perfSm;
|
||||
|
||||
setUp(() {
|
||||
perfController = StreamController<TelemetrySample>.broadcast();
|
||||
clock = FakeMonotonicClock(
|
||||
initialElapsedMs: 10000,
|
||||
initialDateTime: baseTime,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
perfSm?.dispose();
|
||||
await perfController.close();
|
||||
});
|
||||
|
||||
test('10. PerformanceLaunchStateMachine in launched: aborts with gpsLost when GPS becomes stale', () async {
|
||||
perfSm = PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: perfController.stream,
|
||||
clock: clock,
|
||||
);
|
||||
|
||||
perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||||
|
||||
// Stationary fresh sample -> READY
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
monotonicTimestampMs: 1000,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.ready);
|
||||
|
||||
// Launch with fresh sample -> LAUNCHED
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
monotonicTimestampMs: 1100,
|
||||
speed: 5.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.launched);
|
||||
|
||||
// Accelerating fresh sample
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
monotonicTimestampMs: 2000,
|
||||
speed: 40.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.launched);
|
||||
|
||||
// GPS stops advancing. Sample arrives with stale GPS (> 4000ms)
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 6000)),
|
||||
monotonicTimestampMs: 7000,
|
||||
speed: 40.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 1000)),
|
||||
gpsAgeMs: 5000, // STALE
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// MUST abort with gpsLost!
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.aborted);
|
||||
expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost);
|
||||
});
|
||||
|
||||
test('11. PerformanceLaunchStateMachine in ready: does not launch from stale speed', () async {
|
||||
perfSm = PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: perfController.stream,
|
||||
clock: clock,
|
||||
);
|
||||
|
||||
perfSm!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
||||
|
||||
// Stationary fresh sample -> READY
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
monotonicTimestampMs: 1000,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.ready);
|
||||
|
||||
// Stale sample with speed 15 MPH (> 1.0 MPH movement threshold) arrives
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 5000)),
|
||||
monotonicTimestampMs: 6000,
|
||||
speed: 15.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000, // STALE
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// Must NOT launch! Should revert to idle on stale GPS
|
||||
expect(perfSm!.state.state, isNot(PerformanceLaunchState.launched));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.idle);
|
||||
});
|
||||
|
||||
test('12. PerformanceLaunchStateMachine in countdown: aborts with gpsLost when GPS becomes stale', () async {
|
||||
perfSm = PerformanceLaunchStateMachine(
|
||||
db: db,
|
||||
telemetryStream: perfController.stream,
|
||||
clock: clock,
|
||||
);
|
||||
|
||||
perfSm!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
||||
|
||||
// Stationary fresh sample -> READY
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
monotonicTimestampMs: 1000,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.ready);
|
||||
|
||||
// Start countdown
|
||||
perfSm!.startCountdown();
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.countdown);
|
||||
|
||||
// Stale sample arrives during countdown
|
||||
perfController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 5000)),
|
||||
monotonicTimestampMs: 6000,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000, // STALE
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// Must abort with gpsLost!
|
||||
expect(perfSm!.state.state, PerformanceLaunchState.aborted);
|
||||
expect(perfSm!.state.abortReason, PerformanceAbortReason.gpsLost);
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 1F — PerformanceTimerService Stale GPS Gating', () {
|
||||
late StreamController<TelemetrySample> timerController;
|
||||
PerformanceTimerService? timerSm;
|
||||
|
||||
setUp(() {
|
||||
timerController = StreamController<TelemetrySample>.broadcast();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
timerSm?.dispose();
|
||||
await timerController.close();
|
||||
});
|
||||
|
||||
test('13. PerformanceTimerService in timing: aborts when GPS becomes stale', () async {
|
||||
timerSm = PerformanceTimerService(
|
||||
db,
|
||||
timerController.stream,
|
||||
);
|
||||
timerSm!.setTarget(PerformanceTarget.sixty);
|
||||
|
||||
// Stationary fresh sample -> READY
|
||||
timerController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(timerSm!.state.state, PerformanceState.ready);
|
||||
|
||||
// Launch with fresh sample -> TIMING
|
||||
timerController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
speed: 5.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
expect(timerSm!.state.state, PerformanceState.timing);
|
||||
|
||||
// Stale sample arrives (> 4000ms)
|
||||
timerController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 6000)),
|
||||
speed: 40.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
gpsAgeMs: 5900, // STALE
|
||||
));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
// Must abort!
|
||||
expect(timerSm!.state.state, PerformanceState.aborted);
|
||||
|
||||
// And no run should be saved in the database
|
||||
final runs = await db.select(db.performanceRuns).get();
|
||||
expect(runs, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
|
||||
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
|
||||
void main() {
|
||||
final base = DateTime.utc(2026, 9, 19);
|
||||
|
||||
test(
|
||||
'normal READY launch retains interpolation fallback and null-speed handling',
|
||||
() async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final controller = StreamController<TelemetrySample>(sync: true);
|
||||
final timer = PerformanceTimerService(db, controller.stream);
|
||||
addTearDown(() async {
|
||||
timer.dispose();
|
||||
await controller.close();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
timer.setTarget(PerformanceTarget.sixty);
|
||||
for (final age in <int?>[null, 0, 4001]) {
|
||||
controller.add(TelemetrySample(timestamp: base, gpsAgeMs: age));
|
||||
expect(timer.state.state, PerformanceState.ready);
|
||||
}
|
||||
// No previous speed sample: launch time falls back to this timestamp.
|
||||
controller.add(TelemetrySample(timestamp: base, speed: 5, gpsAgeMs: 0));
|
||||
expect(timer.state.state, PerformanceState.timing);
|
||||
for (final age in <int?>[null, 0, 4001]) {
|
||||
controller.add(
|
||||
TelemetrySample(
|
||||
timestamp: base.add(const Duration(milliseconds: 100)),
|
||||
gpsAgeMs: age,
|
||||
),
|
||||
);
|
||||
expect(timer.state.state, PerformanceState.timing);
|
||||
}
|
||||
controller.add(
|
||||
TelemetrySample(
|
||||
timestamp: base.add(const Duration(seconds: 3)),
|
||||
speed: 60,
|
||||
gpsAgeMs: 0,
|
||||
),
|
||||
);
|
||||
expect(timer.state.state, PerformanceState.completed);
|
||||
final runs = await db.select(db.performanceRuns).get();
|
||||
expect(runs, hasLength(1));
|
||||
expect(runs.single.timeMs, 3000);
|
||||
},
|
||||
);
|
||||
|
||||
test('stale READY requires new stationary evidence before launch', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final controller = StreamController<TelemetrySample>(sync: true);
|
||||
final timer = PerformanceTimerService(db, controller.stream);
|
||||
addTearDown(() async {
|
||||
timer.dispose();
|
||||
await controller.close();
|
||||
await db.close();
|
||||
});
|
||||
void emit(int ms, double speed, int age) {
|
||||
controller.add(
|
||||
TelemetrySample(
|
||||
timestamp: base.add(Duration(milliseconds: ms)),
|
||||
speed: speed,
|
||||
gpsAgeMs: age,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
timer.setTarget(PerformanceTarget.sixty);
|
||||
emit(0, 0, 0);
|
||||
expect(timer.state.state, PerformanceState.ready);
|
||||
emit(4000, 0, 4000);
|
||||
expect(timer.state.state, PerformanceState.ready);
|
||||
emit(4001, 0, 4001);
|
||||
expect(timer.state.state, PerformanceState.idle);
|
||||
emit(5000, 30, 5000);
|
||||
emit(6000, 40, 0);
|
||||
expect(timer.state.state, PerformanceState.idle);
|
||||
emit(9000, 65, 0);
|
||||
expect(timer.state.state, PerformanceState.idle);
|
||||
expect(await db.select(db.performanceRuns).get(), isEmpty);
|
||||
|
||||
emit(10000, 0, 0);
|
||||
expect(timer.state.state, PerformanceState.ready);
|
||||
emit(10200, 5, 0);
|
||||
expect(timer.state.state, PerformanceState.timing);
|
||||
emit(13000, 65, 0);
|
||||
expect(timer.state.state, PerformanceState.completed);
|
||||
final runs = await db.select(db.performanceRuns).get();
|
||||
expect(runs, hasLength(1));
|
||||
expect(runs.single.timeMs, closeTo(2727, 1));
|
||||
});
|
||||
|
||||
for (final coordinates in <(double?, double?)>[
|
||||
(null, null),
|
||||
(null, 0),
|
||||
(0, null),
|
||||
(double.nan, 0),
|
||||
(0, double.nan),
|
||||
(double.infinity, 0),
|
||||
(0, double.negativeInfinity),
|
||||
(90.1, 0),
|
||||
(-90.1, 0),
|
||||
(0, 180.1),
|
||||
(0, -180.1),
|
||||
]) {
|
||||
for (final metadata in ['absent', 'age', 'timestamp']) {
|
||||
test('invalid coordinates $coordinates with $metadata are not fresh', () {
|
||||
expect(
|
||||
TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: coordinates.$1,
|
||||
longitude: coordinates.$2,
|
||||
gpsAgeMs: metadata == 'age' ? 0 : null,
|
||||
gpsTimestamp: metadata == 'timestamp' ? base : null,
|
||||
).isGpsFresh,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (final age in [3999, 4000, 4001]) {
|
||||
for (final useAge in [true, false]) {
|
||||
test('GPS boundary $age with explicit age $useAge', () {
|
||||
expect(
|
||||
TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
gpsAgeMs: useAge ? age : null,
|
||||
gpsTimestamp: useAge
|
||||
? null
|
||||
: base.subtract(Duration(milliseconds: age)),
|
||||
).isGpsFresh,
|
||||
age <= 4000,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
test('valid coordinate limits retain metadata-free compatibility', () {
|
||||
for (final coordinates in [(90.0, 180.0), (-90.0, -180.0), (0.0, 0.0)]) {
|
||||
expect(
|
||||
TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: coordinates.$1,
|
||||
longitude: coordinates.$2,
|
||||
).isGpsFresh,
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:motorcycle_dash/models/recording_health_status.dart';
|
||||
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
|
||||
import 'package:motorcycle_dash/features/records/records_service.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/services/location_service.dart';
|
||||
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
||||
import 'package:motorcycle_dash/services/compass_service.dart';
|
||||
import 'package:motorcycle_dash/services/audio_alert_service.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart'
|
||||
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
|
||||
import 'package:drift/native.dart';
|
||||
|
||||
class MockAudioAlertService extends Fake implements AudioAlertService {
|
||||
@override
|
||||
Future<void> speak(String text, {bool force = false}) async {}
|
||||
}
|
||||
|
||||
class FakeTelemetryService extends TelemetryService {
|
||||
final StreamController<TelemetrySample> _controller =
|
||||
StreamController<TelemetrySample>.broadcast();
|
||||
|
||||
FakeTelemetryService(
|
||||
super.loc,
|
||||
super.fusion,
|
||||
super.compass,
|
||||
super.logger,
|
||||
super.validator,
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<TelemetrySample> get telemetryStream => _controller.stream;
|
||||
|
||||
void emitSample(TelemetrySample sample) => _controller.add(sample);
|
||||
|
||||
@override
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class _NullLocationService extends Fake implements LocationService {
|
||||
@override
|
||||
void Function(Position position, bool accepted, String? rejectionReason)?
|
||||
onGpsDiagnostic;
|
||||
@override
|
||||
Stream<Position> getFilteredPositionStream() => const Stream.empty();
|
||||
}
|
||||
|
||||
class _NullSensorFusionService extends Fake implements SensorFusionService {
|
||||
@override
|
||||
Stream<double> get leanAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<double> get pitchAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<GForceData> get gForceStream => const Stream.empty();
|
||||
@override
|
||||
double get latestRollRateDegPerSec => 0.0;
|
||||
@override
|
||||
double get latestRawAccelX => 0.0;
|
||||
@override
|
||||
double get latestRawAccelY => 0.0;
|
||||
@override
|
||||
double get latestRawAccelZ => 0.0;
|
||||
@override
|
||||
double get latestRawGyroX => 0.0;
|
||||
@override
|
||||
double get latestRawGyroY => 0.0;
|
||||
@override
|
||||
double get latestRawGyroZ => 0.0;
|
||||
}
|
||||
|
||||
class _NullCompassService extends Fake implements CompassService {
|
||||
@override
|
||||
Stream<double> get headingStream => const Stream.empty();
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const tickWait = Duration(milliseconds: 3300);
|
||||
const evidenceStaleMs = RecordingHealthContract.evidenceStaleAfterMs; // 4000ms
|
||||
|
||||
late AppDatabase db;
|
||||
late FieldTestLogger logger;
|
||||
late FakeTelemetryService fakeTelemetry;
|
||||
late MockAudioAlertService mockAudio;
|
||||
late TelemetryValidator validator;
|
||||
late SharedPreferences prefs;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
logger = FieldTestLogger(db);
|
||||
validator = TelemetryValidator();
|
||||
fakeTelemetry = FakeTelemetryService(
|
||||
_NullLocationService(),
|
||||
_NullSensorFusionService(),
|
||||
_NullCompassService(),
|
||||
logger,
|
||||
validator,
|
||||
);
|
||||
mockAudio = MockAudioAlertService();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
fakeTelemetry.dispose();
|
||||
logger.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
ProviderContainer buildContainer() {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
databaseProvider.overrideWithValue(db),
|
||||
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
|
||||
audioAlertServiceProvider.overrideWithValue(mockAudio),
|
||||
fieldTestLoggerProvider.overrideWithValue(logger),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
|
||||
|
||||
TelemetrySample createSample({
|
||||
required int monotonicMs,
|
||||
DateTime? gpsTimestamp,
|
||||
int? gpsAgeMs,
|
||||
DateTime? imuTimestamp,
|
||||
double speed = 35.0,
|
||||
}) {
|
||||
return TelemetrySample(
|
||||
timestamp: baseTime.add(Duration(milliseconds: monotonicMs)),
|
||||
monotonicTimestampMs: monotonicMs,
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
speed: speed,
|
||||
gpsTimestamp: gpsTimestamp,
|
||||
gpsAgeMs: gpsAgeMs,
|
||||
imuTimestamp: imuTimestamp ?? baseTime.add(Duration(milliseconds: monotonicMs)),
|
||||
leanAngle: 0.0,
|
||||
pitchAngle: 0.0,
|
||||
longitudinalG: 0.0,
|
||||
lateralG: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reachHealthyActive(ProviderContainer container) async {
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
// Initial evidence at monotonic 1000ms
|
||||
final t1 = baseTime;
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: t1,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
// Wait for periodic batch write timer (3.0s) to commit to DB
|
||||
await Future<void>.delayed(tickWait);
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
// Re-establish sensor freshness
|
||||
final t2 = baseTime.add(tickWait);
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000 + tickWait.inMilliseconds,
|
||||
gpsTimestamp: t2,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
expect(container.read(rideStateMachineProvider).healthStatus,
|
||||
RecordingHealthStatus.recordingActive);
|
||||
}
|
||||
|
||||
group('Phase 1D: Stale-GPS Freshness Semantics', () {
|
||||
test('A. Duplicate GPS timestamp does NOT refresh GPS evidence', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
final gpsT1 = baseTime;
|
||||
|
||||
// Sample 1: first GPS fix at M1 = 1000
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
// Subsequent samples at M2 = 2000, M3 = 3000, M4 = 5500 all carry same gpsT1
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 2000,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 1000,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
// Still within 4000ms evidence window from M1=1000 (age is 1000ms)
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
// At M4 = 5500, elapsed monotonic time since M1 (1000) is 4500ms > evidenceStaleAfterMs (4000)
|
||||
// Because gpsT1 was repeated, GPS evidence was NOT refreshed.
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 5500,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 4500,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
|
||||
reason: 'Duplicate GPS timestamp must not refresh GPS evidence; evidence must be stale');
|
||||
});
|
||||
|
||||
test('B. Genuine newer GPS timestamp refreshes GPS evidence', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
final gpsT1 = baseTime;
|
||||
final gpsT2 = baseTime.add(const Duration(seconds: 4));
|
||||
|
||||
// Sample 1: GPS T1 at M1 = 1000
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
|
||||
// Sample 2: Genuine newer GPS T2 at M2 = 5000 (4000ms later)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 5000,
|
||||
gpsTimestamp: gpsT2,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
// Sample 3 at M3 = 7000 (age relative to M2 is 2000ms <= 4000ms)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 7000,
|
||||
gpsTimestamp: gpsT2,
|
||||
gpsAgeMs: 2000,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
|
||||
reason: 'T2 refreshed GPS evidence at M2=5000, so at M3=7000 it is only 2000ms old and still fresh');
|
||||
});
|
||||
|
||||
test('C. Older/out-of-order GPS timestamp does not refresh GPS evidence', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
final gpsT1 = baseTime;
|
||||
final gpsT2 = baseTime.add(const Duration(seconds: 2));
|
||||
|
||||
// Accept T2 at M1 = 1000
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: gpsT2,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
// Out-of-order older sample carrying T1 (< T2) arrives at M2 = 5500
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 5500,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 4500,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
|
||||
// Monotonic age from M1(1000) to M2(5500) is 4500ms > 4000ms.
|
||||
// Older T1 must not refresh GPS evidence.
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
|
||||
reason: 'Older out-of-order GPS timestamp must not refresh GPS evidence');
|
||||
});
|
||||
|
||||
test('D. Null GPS timestamp does not refresh GPS evidence', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
final gpsT1 = baseTime;
|
||||
|
||||
// Sample with GPS at M1 = 1000
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: gpsT1,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
// Sample without GPS at M2 = 5500
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 5500,
|
||||
gpsTimestamp: null,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isFalse,
|
||||
reason: 'Null GPS timestamp must not refresh GPS evidence');
|
||||
});
|
||||
|
||||
test('E. Normal 10 Hz / 20 Hz operation maintains healthy GPS evidence', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
await notifier.startRide();
|
||||
|
||||
// Simulate 10 GPS fixes (100ms apart), each sampled twice at 20 Hz (50ms interval)
|
||||
int monotonic = 1000;
|
||||
int totalEmitted = 0;
|
||||
|
||||
for (int fix = 0; fix < 10; fix++) {
|
||||
final fixTime = baseTime.add(Duration(milliseconds: fix * 100));
|
||||
|
||||
// First sample for this fix (0ms age)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: monotonic,
|
||||
gpsTimestamp: fixTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
monotonic += 50;
|
||||
totalEmitted++;
|
||||
|
||||
// Second sample for this fix (repackaged cached fix, 50ms age)
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: monotonic,
|
||||
gpsTimestamp: fixTime,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
monotonic += 50;
|
||||
totalEmitted++;
|
||||
}
|
||||
await pumpEventQueue(times: 30);
|
||||
|
||||
final session = container.read(rideStateMachineProvider);
|
||||
expect(session.totalSamplesReceived, totalEmitted,
|
||||
reason: 'Every 20 Hz telemetry sample must be accepted');
|
||||
expect(session.health.isForegroundServiceAlive, isTrue,
|
||||
reason: 'Normal 10 Hz acquisition with 20 Hz sampling must remain healthy');
|
||||
});
|
||||
|
||||
test('F. GPS outage transitions health to recordingDegraded', () async {
|
||||
final container = buildContainer();
|
||||
await reachHealthyActive(container);
|
||||
|
||||
// Starting state: recordingActive
|
||||
expect(container.read(rideStateMachineProvider).healthStatus,
|
||||
RecordingHealthStatus.recordingActive);
|
||||
|
||||
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
|
||||
final cachedGpsTime = baseTime.add(tickWait);
|
||||
|
||||
// Outage occurs: GPS fixes stop, but 20 Hz telemetry loop continues emitting cached fix
|
||||
// Emit samples advancing monotonic time past the 4000ms staleness threshold
|
||||
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500; // 4500ms after last fix
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: outageMonotonic,
|
||||
gpsTimestamp: cachedGpsTime, // Stale fix
|
||||
gpsAgeMs: evidenceStaleMs + 500,
|
||||
));
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
final degradedSession = container.read(rideStateMachineProvider);
|
||||
expect(degradedSession.health.isForegroundServiceAlive, isFalse);
|
||||
expect(degradedSession.health.canReportRecording, isFalse);
|
||||
expect(degradedSession.healthStatus, RecordingHealthStatus.recordingDegraded,
|
||||
reason: 'GPS outage past 4000ms threshold must transition health to recordingDegraded');
|
||||
expect(degradedSession.state, RideState.riding,
|
||||
reason: 'Degradation must not cancel or abort the active ride');
|
||||
});
|
||||
|
||||
test('G. GPS recovery restores recordingActive from recordingDegraded', () async {
|
||||
final container = buildContainer();
|
||||
await reachHealthyActive(container);
|
||||
|
||||
final lastHealthyMonotonic = 1000 + tickWait.inMilliseconds;
|
||||
final cachedGpsTime = baseTime.add(tickWait);
|
||||
|
||||
// 1. Induce outage
|
||||
final outageMonotonic = lastHealthyMonotonic + evidenceStaleMs + 500;
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: outageMonotonic,
|
||||
gpsTimestamp: cachedGpsTime,
|
||||
gpsAgeMs: evidenceStaleMs + 500,
|
||||
));
|
||||
await pumpEventQueue(times: 20);
|
||||
expect(container.read(rideStateMachineProvider).healthStatus,
|
||||
RecordingHealthStatus.recordingDegraded);
|
||||
|
||||
// 2. Recovery: GPS acquires fresh fix
|
||||
final recoveredGpsTime = cachedGpsTime.add(const Duration(seconds: 5));
|
||||
final recoveredMonotonic = outageMonotonic + 50;
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: recoveredMonotonic,
|
||||
gpsTimestamp: recoveredGpsTime, // Genuinely newer fix
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
final recoveredSession = container.read(rideStateMachineProvider);
|
||||
expect(recoveredSession.health.isForegroundServiceAlive, isTrue);
|
||||
expect(recoveredSession.health.canReportRecording, isTrue);
|
||||
expect(recoveredSession.healthStatus, RecordingHealthStatus.recordingActive,
|
||||
reason: 'Fresh GPS fix must recover health from degraded back to recordingActive');
|
||||
});
|
||||
|
||||
test('H. Session reset / start clears last seen GPS timestamp', () async {
|
||||
final container = buildContainer();
|
||||
final notifier = container.read(rideStateMachineProvider.notifier);
|
||||
|
||||
// Ride 1
|
||||
await notifier.startRide();
|
||||
final futureGpsTime = DateTime(2030, 1, 1, 12, 0, 0);
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: futureGpsTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue);
|
||||
|
||||
await notifier.finishRide();
|
||||
await pumpEventQueue(times: 20);
|
||||
|
||||
// Ride 2 starts with normal current time (earlier than Ride 1's future timestamp)
|
||||
await notifier.startRide();
|
||||
final normalGpsTime = DateTime(2026, 9, 19, 12, 0, 0);
|
||||
fakeTelemetry.emitSample(createSample(
|
||||
monotonicMs: 1000,
|
||||
gpsTimestamp: normalGpsTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await pumpEventQueue(times: 10);
|
||||
|
||||
// Must be accepted as fresh evidence for the new ride!
|
||||
expect(container.read(rideStateMachineProvider).health.isForegroundServiceAlive, isTrue,
|
||||
reason: 'New ride start must clear _lastSeenGpsTimestamp so previous ride timestamps are not inherited');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,508 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:motorcycle_dash/features/dashboard/widgets/dashboard_map_panel.dart';
|
||||
import 'package:motorcycle_dash/features/dashboard/widgets/gps_status_indicator.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/services/location_service.dart';
|
||||
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
||||
import 'package:motorcycle_dash/services/compass_service.dart';
|
||||
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
||||
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
||||
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||
import 'package:motorcycle_dash/services/map/unified_map_view.dart';
|
||||
import 'package:motorcycle_dash/database/database.dart'
|
||||
hide TelemetrySample, FieldTestSummary, FieldTestEvent;
|
||||
import 'package:drift/native.dart';
|
||||
|
||||
class FakeLocationService extends Fake implements LocationService {
|
||||
final StreamController<Position> _controller =
|
||||
StreamController<Position>.broadcast();
|
||||
|
||||
@override
|
||||
void Function(Position position, bool accepted, String? rejectionReason)?
|
||||
onGpsDiagnostic;
|
||||
|
||||
@override
|
||||
Stream<Position> getFilteredPositionStream() => _controller.stream;
|
||||
|
||||
void emitPosition(Position position) => _controller.add(position);
|
||||
|
||||
@override
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class FakeCompassService extends Fake implements CompassService {
|
||||
final StreamController<double> _controller =
|
||||
StreamController<double>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<double> get headingStream => _controller.stream;
|
||||
|
||||
void emitHeading(double heading) => _controller.add(heading);
|
||||
|
||||
@override
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
class _NullSensorFusionService extends Fake implements SensorFusionService {
|
||||
@override
|
||||
Stream<double> get leanAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<double> get pitchAngleStream => const Stream.empty();
|
||||
@override
|
||||
Stream<GForceData> get gForceStream => const Stream.empty();
|
||||
@override
|
||||
double get latestRollRateDegPerSec => 0.0;
|
||||
@override
|
||||
double get latestRawAccelX => 0.0;
|
||||
@override
|
||||
double get latestRawAccelY => 0.0;
|
||||
@override
|
||||
double get latestRawAccelZ => 0.0;
|
||||
@override
|
||||
double get latestRawGyroX => 0.0;
|
||||
@override
|
||||
double get latestRawGyroY => 0.0;
|
||||
@override
|
||||
double get latestRawGyroZ => 0.0;
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late AppDatabase db;
|
||||
late FieldTestLogger logger;
|
||||
late TelemetryValidator validator;
|
||||
late FakeLocationService fakeLocation;
|
||||
late FakeCompassService fakeCompass;
|
||||
late TelemetryService telemetryService;
|
||||
|
||||
setUp(() {
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
logger = FieldTestLogger(db);
|
||||
validator = TelemetryValidator();
|
||||
fakeLocation = FakeLocationService();
|
||||
fakeCompass = FakeCompassService();
|
||||
telemetryService = TelemetryService(
|
||||
fakeLocation,
|
||||
_NullSensorFusionService(),
|
||||
fakeCompass,
|
||||
logger,
|
||||
validator,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
telemetryService.dispose();
|
||||
fakeLocation.dispose();
|
||||
fakeCompass.dispose();
|
||||
logger.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
Position createPosition({
|
||||
required DateTime timestamp,
|
||||
double speedMps = 20.0, // ~45 MPH
|
||||
double heading = 90.0,
|
||||
double lat = 34.0522,
|
||||
double lon = -118.2437,
|
||||
double accuracy = 5.0,
|
||||
}) {
|
||||
return Position(
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
timestamp: timestamp,
|
||||
altitude: 100.0,
|
||||
altitudeAccuracy: 1.0,
|
||||
accuracy: accuracy,
|
||||
heading: heading,
|
||||
headingAccuracy: 1.0,
|
||||
speed: speedMps,
|
||||
speedAccuracy: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
group('Phase 1G — Heading Selection Truthfulness in TelemetryService', () {
|
||||
test('1. Fresh moving GPS heading remains usable and preferred over compass', () async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Emit live compass = 180°
|
||||
fakeCompass.emitHeading(180.0);
|
||||
// Emit fresh GPS fix moving at 45 MPH heading 90°
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: now,
|
||||
speedMps: 20.0,
|
||||
heading: 90.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final sample = await telemetryService.telemetryStream.first;
|
||||
// Moving GPS heading (90°) should override compass (180°)
|
||||
expect(sample.heading, 90.0);
|
||||
expect(sample.speed, greaterThan(3.0));
|
||||
expect(sample.isGpsFresh, isTrue);
|
||||
});
|
||||
|
||||
test('2. Normal duplicate GPS fixes (10 Hz / 20 Hz) do not cause heading flicker', () async {
|
||||
final now = DateTime.now();
|
||||
|
||||
fakeCompass.emitHeading(220.0);
|
||||
// Fix arrives 100ms ago (simulating 10 Hz acquisition sampled at 20 Hz)
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: now.subtract(const Duration(milliseconds: 100)),
|
||||
speedMps: 15.0,
|
||||
heading: 75.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final sample = await telemetryService.telemetryStream.first;
|
||||
// Within 4000ms bound, duplicate fix heading remains stable
|
||||
expect(sample.heading, 75.0);
|
||||
expect(sample.isGpsFresh, isTrue);
|
||||
});
|
||||
|
||||
test('3. Stale GPS heading stops overriding live compass after 4000ms', () async {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Live compass is 270° West
|
||||
fakeCompass.emitHeading(270.0);
|
||||
// GPS fix is stale (> 4000ms ago, e.g. 5000ms ago) with heading 90° East
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: now.subtract(const Duration(milliseconds: 5000)),
|
||||
speedMps: 20.0,
|
||||
heading: 90.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final sample = await telemetryService.telemetryStream.first;
|
||||
// Stale GPS must NOT override compass! Heading must fall back to 270°
|
||||
expect(sample.heading, 270.0);
|
||||
expect(sample.isGpsFresh, isFalse);
|
||||
});
|
||||
|
||||
test('4. Heading recovers automatically when fresh GPS resumes', () async {
|
||||
final now = DateTime.now();
|
||||
|
||||
fakeCompass.emitHeading(180.0);
|
||||
// Initially stale GPS (5000ms ago, heading 90°)
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: now.subtract(const Duration(milliseconds: 5000)),
|
||||
speedMps: 20.0,
|
||||
heading: 90.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
final staleSample = await telemetryService.telemetryStream.first;
|
||||
expect(staleSample.heading, 180.0); // Compass used
|
||||
expect(staleSample.isGpsFresh, isFalse);
|
||||
|
||||
// Now fresh GPS fix arrives: heading 120°, moving
|
||||
final freshNow = DateTime.now();
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: freshNow,
|
||||
speedMps: 22.0,
|
||||
heading: 120.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
final recoveredSample = await telemetryService.telemetryStream.first;
|
||||
// Fresh GPS automatically becomes eligible again!
|
||||
expect(recoveredSample.heading, 120.0);
|
||||
expect(recoveredSample.isGpsFresh, isTrue);
|
||||
});
|
||||
|
||||
test('5. Heading and GPS boundary condition at 3999ms, 4000ms, and 4001ms', () {
|
||||
final base = DateTime(2026, 9, 19, 12, 0, 0);
|
||||
|
||||
// A. 3999 ms -> FRESH
|
||||
final sample3999 = TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
speed: 40.0,
|
||||
heading: 45.0,
|
||||
gpsTimestamp: base.subtract(const Duration(milliseconds: 3999)),
|
||||
gpsAgeMs: 3999,
|
||||
);
|
||||
expect(sample3999.isGpsFresh, isTrue);
|
||||
|
||||
// B. 4000 ms -> FRESH (inclusive canonical bound)
|
||||
final sample4000 = TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
speed: 40.0,
|
||||
heading: 45.0,
|
||||
gpsTimestamp: base.subtract(const Duration(milliseconds: 4000)),
|
||||
gpsAgeMs: 4000,
|
||||
);
|
||||
expect(sample4000.isGpsFresh, isTrue);
|
||||
|
||||
// C. 4001 ms -> STALE (exceeds canonical bound)
|
||||
final sample4001 = TelemetrySample(
|
||||
timestamp: base,
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
speed: 40.0,
|
||||
heading: 45.0,
|
||||
gpsTimestamp: base.subtract(const Duration(milliseconds: 4001)),
|
||||
gpsAgeMs: 4001,
|
||||
);
|
||||
expect(sample4001.isGpsFresh, isFalse);
|
||||
});
|
||||
|
||||
test('6. Fresh GPS at stationary/low speed (<= 3.0 MPH) uses compass', () async {
|
||||
fakeCompass.emitHeading(42.0);
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: DateTime.now(),
|
||||
speedMps: 0.5, // ~1.1 MPH <= 3.0 MPH
|
||||
heading: 90.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
final sample = await telemetryService.telemetryStream.first;
|
||||
// Low speed correctly preserves compass fallback
|
||||
expect(sample.heading, 42.0);
|
||||
});
|
||||
|
||||
test('7. Raw telemetry values (lat, lon, gpsTimestamp, gpsAgeMs) remain preserved during stale GPS', () async {
|
||||
final fixTime = DateTime.now().subtract(const Duration(milliseconds: 7000));
|
||||
fakeCompass.emitHeading(180.0);
|
||||
fakeLocation.emitPosition(createPosition(
|
||||
timestamp: fixTime,
|
||||
lat: 37.7749,
|
||||
lon: -122.4194,
|
||||
speedMps: 20.0,
|
||||
heading: 90.0,
|
||||
));
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
final sample = await telemetryService.telemetryStream.first;
|
||||
|
||||
// Raw values must NOT be nulled or altered
|
||||
expect(sample.latitude, 37.7749);
|
||||
expect(sample.longitude, -122.4194);
|
||||
expect(sample.gpsTimestamp, fixTime);
|
||||
expect(sample.gpsAgeMs, greaterThanOrEqualTo(7000));
|
||||
// But freshness flags truthful status
|
||||
expect(sample.isGpsFresh, isFalse);
|
||||
expect(sample.isSpeedFresh, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 1G — DashboardMapPanel & Location Presentation Truthfulness', () {
|
||||
late StreamController<TelemetrySample> testStreamController;
|
||||
|
||||
setUp(() {
|
||||
testStreamController = StreamController<TelemetrySample>.broadcast();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await testStreamController.close();
|
||||
});
|
||||
|
||||
Widget buildTestWidget({required Stream<TelemetrySample> stream}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
telemetryStreamProvider.overrideWith((ref) => stream),
|
||||
activeAlertsStreamProvider.overrideWith((ref) => Stream.value(<RoadAlert>[])),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 400,
|
||||
height: 400,
|
||||
child: DashboardMapPanel(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('8. Fresh map coordinate renders normal active marker without stale badge', (tester) async {
|
||||
final now = DateTime.now();
|
||||
final freshSample = TelemetrySample(
|
||||
timestamp: now,
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
heading: 90.0,
|
||||
speed: 35.0,
|
||||
gpsTimestamp: now,
|
||||
gpsAgeMs: 50,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
|
||||
testStreamController.add(freshSample);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stale pill badge must NOT be present
|
||||
expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing);
|
||||
expect(find.text('LAST KNOWN LOCATION'), findsNothing);
|
||||
|
||||
// Map view is rendered
|
||||
expect(find.byType(UnifiedMapView), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('9. Stale map coordinate renders LAST KNOWN LOCATION pill badge and dimmed marker', (tester) async {
|
||||
final now = DateTime.now();
|
||||
final staleSample = TelemetrySample(
|
||||
timestamp: now,
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
heading: 90.0,
|
||||
speed: 35.0,
|
||||
gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)),
|
||||
gpsAgeMs: 6000, // > 4000ms -> STALE
|
||||
);
|
||||
|
||||
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
|
||||
testStreamController.add(staleSample);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stale pill badge MUST be present
|
||||
expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget);
|
||||
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.location_off), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('10. Stale coordinate does NOT move artificially (no dead reckoning / fake drift)', (tester) async {
|
||||
final now = DateTime.now();
|
||||
const initialLat = 34.0522;
|
||||
const initialLon = -118.2437;
|
||||
|
||||
// 5 seconds of stale samples at 20 Hz
|
||||
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
testStreamController.add(TelemetrySample(
|
||||
timestamp: now.add(Duration(milliseconds: i * 50)),
|
||||
latitude: initialLat,
|
||||
longitude: initialLon,
|
||||
heading: 90.0,
|
||||
speed: 45.0, // Cached speed
|
||||
gpsTimestamp: now.subtract(const Duration(milliseconds: 5000)),
|
||||
gpsAgeMs: 5000 + (i * 50),
|
||||
));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Map center must remain exactly initialLat, initialLon
|
||||
final mapWidget = tester.widget<UnifiedMapView>(find.byType(UnifiedMapView));
|
||||
expect(mapWidget.center.latitude, initialLat);
|
||||
expect(mapWidget.center.longitude, initialLon);
|
||||
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('11. Map presentation recovers automatically when fresh GPS resumes', (tester) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
|
||||
|
||||
// 1. Emit stale sample
|
||||
testStreamController.add(TelemetrySample(
|
||||
timestamp: now,
|
||||
latitude: 34.0522,
|
||||
longitude: -118.2437,
|
||||
heading: 90.0,
|
||||
speed: 35.0,
|
||||
gpsTimestamp: now.subtract(const Duration(milliseconds: 6000)),
|
||||
gpsAgeMs: 6000,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('stale_gps_pill')), findsOneWidget);
|
||||
|
||||
// 2. Emit fresh sample (GPS recovered)
|
||||
final recoveredNow = DateTime.now();
|
||||
testStreamController.add(TelemetrySample(
|
||||
timestamp: recoveredNow,
|
||||
latitude: 34.0530,
|
||||
longitude: -118.2430,
|
||||
heading: 95.0,
|
||||
speed: 35.0,
|
||||
gpsTimestamp: recoveredNow,
|
||||
gpsAgeMs: 10,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Stale badge disappears automatically
|
||||
expect(find.byKey(const ValueKey('stale_gps_pill')), findsNothing);
|
||||
expect(find.text('LAST KNOWN LOCATION'), findsNothing);
|
||||
|
||||
// Coordinates updated to fresh location
|
||||
final mapWidget = tester.widget<UnifiedMapView>(find.byType(UnifiedMapView));
|
||||
expect(mapWidget.center.latitude, 34.0530);
|
||||
expect(mapWidget.center.longitude, -118.2430);
|
||||
});
|
||||
|
||||
testWidgets('12. Truthfulness operates independently of ride state (outside an active ride)', (tester) async {
|
||||
// Without any ride session started or active, freshness and stale badge work directly from telemetry stream
|
||||
final now = DateTime.now();
|
||||
final staleSample = TelemetrySample(
|
||||
timestamp: now,
|
||||
latitude: 40.7128,
|
||||
longitude: -74.0060,
|
||||
heading: 180.0,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: now.subtract(const Duration(milliseconds: 5500)),
|
||||
gpsAgeMs: 5500,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(buildTestWidget(stream: testStreamController.stream));
|
||||
testStreamController.add(staleSample);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('LAST KNOWN LOCATION'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('13. GPSStatusIndicator truthfully shows NO GPS when fix is stale (> 4000ms)', (tester) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
Widget buildGpsWidget({required Stream<TelemetrySample> stream}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
telemetryStreamProvider.overrideWith((ref) => stream),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: GPSStatusIndicator(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Fresh fix with accuracy = 5.0 -> GPS 3D FIX
|
||||
await tester.pumpWidget(buildGpsWidget(stream: testStreamController.stream));
|
||||
testStreamController.add(TelemetrySample(
|
||||
timestamp: now,
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
gpsAccuracy: 5.0,
|
||||
gpsTimestamp: now,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('GPS 3D FIX'), findsOneWidget);
|
||||
|
||||
// 2. Stale fix with accuracy = 5.0 (> 4000ms) -> NO GPS
|
||||
testStreamController.add(TelemetrySample(
|
||||
timestamp: now.add(const Duration(seconds: 5)),
|
||||
latitude: 34.0,
|
||||
longitude: -85.0,
|
||||
gpsAccuracy: 5.0, // cached accuracy
|
||||
gpsTimestamp: now,
|
||||
gpsAgeMs: 5000, // STALE
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('NO GPS'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.gps_off), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
||||
import 'package:motorcycle_dash/services/telemetry_service.dart';
|
||||
import 'package:motorcycle_dash/features/dashboard/widgets/speed_readout.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final baseTime = DateTime(2026, 9, 19, 12, 0, 0);
|
||||
|
||||
group('Phase 1E: TelemetrySample Speed Freshness Semantic Model', () {
|
||||
test('A. Normal moving GPS provides fresh speed', () {
|
||||
final sample = TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 50.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
);
|
||||
|
||||
expect(sample.isSpeedFresh, isTrue);
|
||||
expect(sample.freshSpeed, 50.0);
|
||||
});
|
||||
|
||||
test('B. Legitimate 10 Hz / 20 Hz duplication remains fresh within 4000ms bound', () {
|
||||
final sample1 = TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 50.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
);
|
||||
final sample2 = TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 50)),
|
||||
speed: 50.0,
|
||||
gpsTimestamp: baseTime, // Cached fix reused 50ms later
|
||||
gpsAgeMs: 50,
|
||||
);
|
||||
|
||||
expect(sample1.isSpeedFresh, isTrue);
|
||||
expect(sample1.freshSpeed, 50.0);
|
||||
expect(sample2.isSpeedFresh, isTrue);
|
||||
expect(sample2.freshSpeed, 50.0);
|
||||
});
|
||||
|
||||
test('C & D. GNSS outage: stale speed becomes untrustworthy and does not fabricate zero', () {
|
||||
final staleSample = TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 5)),
|
||||
speed: 50.0, // Cached speed from 5s ago
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000, // Exceeds 4000ms threshold
|
||||
);
|
||||
|
||||
expect(staleSample.isSpeedFresh, isFalse,
|
||||
reason: 'GPS fix age > 4000ms must be marked as stale');
|
||||
expect(staleSample.freshSpeed, isNull,
|
||||
reason: 'Stale speed must be null, never presenting old speed as current');
|
||||
expect(staleSample.speed, 50.0,
|
||||
reason: 'Underlying raw speed is preserved for forensic analysis');
|
||||
expect(staleSample.freshSpeed, isNot(0.0),
|
||||
reason: 'Must NOT fabricate 0.0 MPH for a stale condition');
|
||||
});
|
||||
|
||||
test('E. Genuine stopped motorcycle is distinguishable from stale/unavailable GPS', () {
|
||||
final stoppedSample = TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
);
|
||||
|
||||
expect(stoppedSample.isSpeedFresh, isTrue);
|
||||
expect(stoppedSample.freshSpeed, 0.0,
|
||||
reason: 'Fresh 0.0 MPH must be recognized as valid zero-speed measurement');
|
||||
});
|
||||
|
||||
test('F. Recovery from outage produces fresh speed again', () {
|
||||
final recoveredSample = TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 10)),
|
||||
speed: 47.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(seconds: 10)),
|
||||
gpsAgeMs: 0,
|
||||
);
|
||||
|
||||
expect(recoveredSample.isSpeedFresh, isTrue);
|
||||
expect(recoveredSample.freshSpeed, 47.0);
|
||||
});
|
||||
|
||||
test('Null speed fix is not fresh', () {
|
||||
final nullSpeedSample = TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: null,
|
||||
);
|
||||
|
||||
expect(nullSpeedSample.isSpeedFresh, isFalse);
|
||||
expect(nullSpeedSample.freshSpeed, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('Phase 1E: SpeedReadout Widget Presentation Tests', () {
|
||||
late StreamController<TelemetrySample> telemetryController;
|
||||
|
||||
setUp(() {
|
||||
telemetryController = StreamController<TelemetrySample>.broadcast();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
telemetryController.close();
|
||||
});
|
||||
|
||||
Widget buildTestHarness({SpeedReadoutSize size = SpeedReadoutSize.regular}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SpeedReadout(size: size),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Initial state before telemetry emits shows 0', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('MPH'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('A. Normal moving GPS renders fresh numerical speed', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump();
|
||||
|
||||
// Emit 48 MPH
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 48.2,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('48'), findsOneWidget);
|
||||
|
||||
// Emit 50 MPH
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
speed: 50.1,
|
||||
gpsTimestamp: baseTime.add(const Duration(milliseconds: 100)),
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('50'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('B. Legitimate 10 Hz / 20 Hz duplication renders smoothly without dropping to --', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
final t1 = baseTime;
|
||||
|
||||
// Tick 1: New fix T1 (50 MPH)
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: t1,
|
||||
speed: 50.0,
|
||||
gpsTimestamp: t1,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('50'), findsOneWidget);
|
||||
|
||||
// Tick 2: 50ms later, repackaged T1 (50 MPH, age 50ms)
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: t1.add(const Duration(milliseconds: 50)),
|
||||
speed: 50.0,
|
||||
gpsTimestamp: t1,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('50'), findsOneWidget,
|
||||
reason: 'Intermediate 20 Hz duplicate must continue displaying 50 smoothly');
|
||||
expect(find.text('--'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('C & D. GNSS outage displays -- and does NOT fabricate 0 or keep 50 MPH', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// Initial fresh 50 MPH
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 50.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('50'), findsOneWidget);
|
||||
|
||||
// GPS outage: 5 seconds elapse without new GPS fix
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 5)),
|
||||
speed: 50.0, // Stale cached speed
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000, // Stale age > 4000ms
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// UI must no longer display 50
|
||||
expect(find.text('50'), findsNothing,
|
||||
reason: 'Stale 50 MPH speed must not remain displayed during an outage');
|
||||
// UI must display '--'
|
||||
expect(find.text('--'), findsOneWidget,
|
||||
reason: 'Stale speed must be rendered as "--" placeholder');
|
||||
// UI must NOT fabricate '0'
|
||||
expect(find.text('0'), findsNothing,
|
||||
reason: 'Stale speed must NOT be fabricated as 0 MPH');
|
||||
});
|
||||
|
||||
testWidgets('E. Genuine stopped motorcycle renders 0, distinguishable from --', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// Fresh 0.0 MPH fix
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 0.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('0'), findsOneWidget,
|
||||
reason: 'Fresh stopped reading must display "0"');
|
||||
expect(find.text('--'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('F. Recovery from outage restores speed display automatically', (tester) async {
|
||||
await tester.pumpWidget(buildTestHarness());
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// 1. Enter outage
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 5)),
|
||||
speed: 50.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('--'), findsOneWidget);
|
||||
|
||||
// 2. Recovery fix arrives with 47 MPH
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 6)),
|
||||
speed: 47.0,
|
||||
gpsTimestamp: baseTime.add(const Duration(seconds: 6)),
|
||||
gpsAgeMs: 0,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('47'), findsOneWidget,
|
||||
reason: 'Recovery must automatically display fresh speed without user intervention');
|
||||
expect(find.text('--'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('G. Ride-independent behavior: works outside an active ride', (tester) async {
|
||||
// Build test harness with rideStateMachineProvider in idle state (not riding)
|
||||
final harness = ProviderScope(
|
||||
overrides: [
|
||||
telemetryStreamProvider.overrideWith((ref) => telemetryController.stream),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SpeedReadout(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(harness);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
// Outside a ride, fresh speed renders normally
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime,
|
||||
speed: 35.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 50,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('35'), findsOneWidget);
|
||||
|
||||
// Outside a ride, stale speed becomes '--'
|
||||
telemetryController.add(TelemetrySample(
|
||||
timestamp: baseTime.add(const Duration(seconds: 5)),
|
||||
speed: 35.0,
|
||||
gpsTimestamp: baseTime,
|
||||
gpsAgeMs: 5000,
|
||||
));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
expect(find.text('--'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue