713 lines
25 KiB
Dart
713 lines
25 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|