632 lines
28 KiB
Dart
632 lines
28 KiB
Dart
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/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/records/records_service.dart';
|
|
import 'package:motorcycle_dash/models/telemetry_sample.dart';
|
|
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late AppDatabase db;
|
|
late StreamController<TelemetrySample> telemetryController;
|
|
late FakeMonotonicClock clock;
|
|
late RecordsService recordsService;
|
|
late SharedPreferences prefs;
|
|
PerformanceLaunchStateMachine? stateMachine;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
prefs = await SharedPreferences.getInstance();
|
|
recordsService = RecordsService(prefs);
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
telemetryController = StreamController<TelemetrySample>.broadcast();
|
|
clock = FakeMonotonicClock(
|
|
initialElapsedMs: 10000,
|
|
initialDateTime: DateTime.utc(2026, 9, 5, 12, 0, 0),
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
stateMachine?.dispose();
|
|
await telemetryController.close();
|
|
await db.close();
|
|
});
|
|
|
|
TelemetrySample makeSample({
|
|
required double speed,
|
|
int? monotonicMs,
|
|
DateTime? timestamp,
|
|
int? seq,
|
|
ValidationStatus status = ValidationStatus.valid,
|
|
double confidence = 1.0,
|
|
}) {
|
|
final ms = monotonicMs ?? clock.elapsedMilliseconds;
|
|
final dt = timestamp ?? clock.now;
|
|
return TelemetrySample(
|
|
timestamp: dt,
|
|
monotonicTimestampMs: ms,
|
|
sequenceNumber: seq,
|
|
speed: speed,
|
|
quality: TelemetryQuality(
|
|
status: status,
|
|
overallConfidence: confidence,
|
|
speedConfidence: confidence,
|
|
gpsConfidence: confidence,
|
|
),
|
|
);
|
|
}
|
|
|
|
group('Performance Launch Mode — Auto Launch Tests', () {
|
|
test('1. IDLE -> READY after valid stationary condition', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
// Initially idle
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.idle);
|
|
|
|
// Arm auto launch
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.idle);
|
|
|
|
// Emit stationary valid sample (< 1.0 MPH)
|
|
telemetryController.add(makeSample(speed: 0.0));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.ready);
|
|
});
|
|
|
|
test('2. READY does not occur with invalid telemetry', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
|
|
// Emit rejected sample
|
|
telemetryController.add(makeSample(speed: 0.0, status: ValidationStatus.rejected, confidence: 0.0));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.idle);
|
|
});
|
|
|
|
test('3. movement starts timer (READY -> LAUNCHED)', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.ready);
|
|
|
|
// Vehicle launches past 1.0 MPH threshold
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
expect(stateMachine!.state.movementDetectedAtMonotonicMs, isNotNull);
|
|
expect(stateMachine!.state.movementDetectedAtMonotonicMs, greaterThanOrEqualTo(1000));
|
|
expect(stateMachine!.state.movementDetectedAtMonotonicMs, lessThanOrEqualTo(1050));
|
|
});
|
|
|
|
test('4. 60 MPH threshold completes 0-60', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
recordsService: recordsService,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Launch at t=1050ms
|
|
telemetryController.add(makeSample(speed: 4.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Mid run at t=2500ms (speed 45)
|
|
telemetryController.add(makeSample(speed: 45.0, monotonicMs: 2500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Cross 60 MPH at t=3800ms (speed 62.0)
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3800));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
expect(stateMachine!.state.lastResult, isNotNull);
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, greaterThan(2.0));
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, lessThan(3.5));
|
|
expect(stateMachine!.state.lastResult!.target, PerformanceLaunchTarget.sixty);
|
|
expect(stateMachine!.state.lastResult!.isFalseStart, isFalse);
|
|
|
|
// Verify persisted in database
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
expect(runs.single.type, '0-60');
|
|
});
|
|
|
|
test('5. 100 MPH target captures intermediate 60 split', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
recordsService: recordsService,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.hundred);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Launch at t=1050ms
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// 60 MPH split crossing at t=3500ms
|
|
telemetryController.add(makeSample(speed: 63.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.splits, hasLength(1));
|
|
expect(stateMachine!.state.splits.single.targetSpeedMph, 60.0);
|
|
expect(stateMachine!.state.splits.single.elapsedFromMovementSec, greaterThan(2.0));
|
|
|
|
// 100 MPH target crossing at t=6500ms
|
|
telemetryController.add(makeSample(speed: 102.0, monotonicMs: 6500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
expect(stateMachine!.state.lastResult!.target, PerformanceLaunchTarget.hundred);
|
|
expect(stateMachine!.state.lastResult!.splits, hasLength(1));
|
|
expect(stateMachine!.state.lastResult!.split60TimeSec, isNotNull);
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, greaterThan(5.0));
|
|
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
expect(runs.single.type, '0-100');
|
|
});
|
|
|
|
test('6. target crossing only completes once', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
|
|
// Additional high speed samples after completion
|
|
telemetryController.add(makeSample(speed: 70.0, monotonicMs: 3800));
|
|
telemetryController.add(makeSample(speed: 80.0, monotonicMs: 4000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
});
|
|
});
|
|
|
|
group('Performance Launch Mode — Countdown Tests', () {
|
|
test('7. READY -> COUNTDOWN', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.ready);
|
|
|
|
stateMachine!.startCountdown();
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.countdown);
|
|
expect(stateMachine!.state.scheduledGoAtMonotonicMs, isNotNull);
|
|
});
|
|
|
|
test('8. countdown uses monotonic schedule & 9. GO timestamp is deterministic', () async {
|
|
clock.setElapsedMs(2000);
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
countdownDurationMs: 3000,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 2000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
stateMachine!.startCountdown();
|
|
expect(stateMachine!.state.countdownStartedAtMonotonicMs, 2000);
|
|
expect(stateMachine!.state.scheduledGoAtMonotonicMs, 5000);
|
|
});
|
|
|
|
test('10. movement after GO produces positive Launch Reaction', () async {
|
|
clock.setElapsedMs(1000);
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
countdownDurationMs: 3000,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
stateMachine!.startCountdown(); // scheduledGo = 4000
|
|
|
|
// Sample at GO (t=4000, stationary)
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 4000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.goAtMonotonicMs, 4000);
|
|
|
|
// Rider launches at t=4250 (250ms reaction time)
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 4250));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
expect(stateMachine!.state.isFalseStart, isFalse);
|
|
|
|
// Finish 0-60 at t=7000 (speed 61)
|
|
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 7000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
expect(stateMachine!.state.lastResult!.reactionTimeSec, isNotNull);
|
|
expect(stateMachine!.state.lastResult!.reactionTimeSec!, closeTo(0.05, 0.01));
|
|
expect(stateMachine!.state.lastResult!.totalTimeFromGoSec, greaterThan(2.5));
|
|
});
|
|
|
|
test('11. movement exactly at GO produces zero Launch Reaction', () async {
|
|
clock.setElapsedMs(1000);
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
countdownDurationMs: 3000,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
stateMachine!.startCountdown(); // scheduledGo = 4000
|
|
|
|
// Vehicle moves right across 1.0 MPH exactly at t=4000
|
|
telemetryController.add(makeSample(speed: 1.0, monotonicMs: 4000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.isFalseStart, isFalse);
|
|
});
|
|
|
|
test('12. movement before GO records FALSE START & 13. false start not discarded & 14. acceleration timing still works', () async {
|
|
clock.setElapsedMs(1000);
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
recordsService: recordsService,
|
|
clock: clock,
|
|
countdownDurationMs: 3000,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
stateMachine!.startCountdown(); // scheduledGo = 4000
|
|
|
|
// Vehicle jumps before GO at t=3500ms (500ms early)
|
|
telemetryController.add(makeSample(speed: 6.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
expect(stateMachine!.state.isFalseStart, isTrue);
|
|
|
|
// Rider continues accelerating and crosses 60 MPH at t=6000ms
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 6000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
expect(stateMachine!.state.lastResult!.isFalseStart, isTrue);
|
|
expect(stateMachine!.state.lastResult!.reactionTimeSec, isNotNull);
|
|
expect(stateMachine!.state.lastResult!.reactionTimeSec!, lessThan(0.0)); // Negative reaction
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, greaterThan(2.0));
|
|
|
|
// Persisted in DB as record of run
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
|
|
// But personal records service NOT updated because it was a false start
|
|
expect(recordsService.state.fastest0To60, double.infinity);
|
|
});
|
|
});
|
|
|
|
group('Performance Launch Mode — Monotonic Timing & Resiliency Tests', () {
|
|
test('15. wall-clock change does not affect elapsed timing', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000, timestamp: DateTime.utc(2026, 9, 5, 12, 0, 0)));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050, timestamp: DateTime.utc(2026, 9, 5, 12, 0, 0, 50)));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Simulate abrupt wall clock shift (e.g. NTP backwards adjustment by 1 hour)
|
|
clock.setWallClock(DateTime.utc(2026, 9, 5, 11, 0, 0));
|
|
|
|
// Finish run at monotonicMs = 3800 with shifted wall-clock timestamp
|
|
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3800, timestamp: DateTime.utc(2026, 9, 5, 11, 0, 2)));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
// Measured elapsed time should be purely derived from monotonic clock (~2.75s)
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, greaterThan(2.5));
|
|
expect(stateMachine!.state.lastResult!.accelerationTimeSec, lessThan(3.0));
|
|
});
|
|
|
|
test('16. monotonic timestamp ordering enforced & 17. stale/out-of-order samples ignored', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000, seq: 10));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1100, seq: 11));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Stale out-of-order sample (older monotonic ms and seq)
|
|
telemetryController.add(makeSample(speed: 65.0, monotonicMs: 900, seq: 8));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// State machine should NOT complete from stale out-of-order sample
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
});
|
|
|
|
test('18. duplicate samples cannot complete twice', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000, seq: 1));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050, seq: 2));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500, seq: 3));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// Duplicate sample replay with same seq
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500, seq: 3));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
});
|
|
});
|
|
|
|
group('Performance Launch Mode — Lifecycle & Integration Tests', () {
|
|
test('19. cancel before movement', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.ready);
|
|
|
|
stateMachine!.cancel();
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.aborted);
|
|
expect(stateMachine!.state.abortReason, PerformanceAbortReason.userCancelled);
|
|
});
|
|
|
|
test('20. cancel after movement', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 20.0, monotonicMs: 1200));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
|
|
stateMachine!.cancel();
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.aborted);
|
|
expect(stateMachine!.state.abortReason, PerformanceAbortReason.userCancelled);
|
|
});
|
|
|
|
test('21. telemetry invalidation aborts active timing', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 20.0, monotonicMs: 1200));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
|
|
// Corrupted / rejected telemetry sample received
|
|
telemetryController.add(makeSample(speed: 25.0, monotonicMs: 1300, status: ValidationStatus.rejected));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.aborted);
|
|
expect(stateMachine!.state.abortReason, PerformanceAbortReason.telemetryRejected);
|
|
});
|
|
|
|
test('22. reset after completed attempt', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.completed);
|
|
|
|
stateMachine!.reset();
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.idle);
|
|
expect(stateMachine!.state.lastResult, isNull);
|
|
});
|
|
|
|
test('23. cannot arm a second concurrent attempt while launched', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 25.0, monotonicMs: 1200));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
|
|
// Attempt to re-arm while timing
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.countdown, target: PerformanceLaunchTarget.hundred);
|
|
// State should remain launched with original 0-60 target
|
|
expect(stateMachine!.state.state, PerformanceLaunchState.launched);
|
|
expect(stateMachine!.state.target, PerformanceLaunchTarget.sixty);
|
|
});
|
|
|
|
test('24. active ride identity carried into result', () async {
|
|
final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 5, 12));
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
activeRideIdReader: () => rideId,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(stateMachine!.state.lastResult!.rideId, rideId);
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs.single.rideId, rideId);
|
|
});
|
|
|
|
test('25. no duplicate PerformanceRun persistence on multiple queries', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
});
|
|
|
|
test('26. existing PR validation remains authoritative on clean runs', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
recordsService: recordsService,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.sixty);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
expect(recordsService.state.fastest0To60, isNot(double.infinity));
|
|
expect(recordsService.state.fastest0To60, greaterThan(1.8)); // within PR sanity limit
|
|
});
|
|
|
|
test('27. 0-100 attempt uses ONE timeline with 60 MPH split', () async {
|
|
stateMachine = PerformanceLaunchStateMachine(
|
|
db: db,
|
|
telemetryStream: telemetryController.stream,
|
|
clock: clock,
|
|
);
|
|
|
|
stateMachine!.arm(mode: PerformanceLaunchMode.auto, target: PerformanceLaunchTarget.hundred);
|
|
telemetryController.add(makeSample(speed: 0.0, monotonicMs: 1000));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// 60 MPH intermediate crossing
|
|
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3600));
|
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
|
|
|
// 100 MPH target crossing
|
|
telemetryController.add(makeSample(speed: 101.0, monotonicMs: 6800));
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
|
|
// Only ONE PerformanceRun row created for 0-100 attempt
|
|
final runs = await db.select(db.performanceRuns).get();
|
|
expect(runs, hasLength(1));
|
|
expect(runs.single.type, '0-100');
|
|
|
|
// Result contains both total time and split
|
|
final result = stateMachine!.state.lastResult!;
|
|
expect(result.target, PerformanceLaunchTarget.hundred);
|
|
expect(result.splits, hasLength(1));
|
|
expect(result.split60TimeSec, isNotNull);
|
|
expect(result.accelerationTimeSec, greaterThan(result.split60TimeSec!));
|
|
});
|
|
});
|
|
}
|