diff --git a/lib/features/performance/models/performance_launch_models.dart b/lib/features/performance/models/performance_launch_models.dart new file mode 100644 index 0000000..d33ce8a --- /dev/null +++ b/lib/features/performance/models/performance_launch_models.dart @@ -0,0 +1,223 @@ +/// Supported start modes for Performance Launch Mode. +enum PerformanceLaunchMode { + /// Automatic launch: timing begins immediately when vehicle movement >= 1.0 MPH is validated. + auto, + + /// Countdown launch: monotonic 3-2-1-GO countdown; measures reaction time and false starts. + countdown, +} + +/// Target speed thresholds. +enum PerformanceLaunchTarget { + /// 0 to 60 MPH acceleration benchmark. + sixty, + + /// 0 to 100 MPH acceleration benchmark (captures 60 MPH split). + hundred, +} + +extension PerformanceLaunchTargetExt on PerformanceLaunchTarget { + double get targetSpeedMph => switch (this) { + PerformanceLaunchTarget.sixty => 60.0, + PerformanceLaunchTarget.hundred => 100.0, + }; + + String get label => switch (this) { + PerformanceLaunchTarget.sixty => '0-60 MPH', + PerformanceLaunchTarget.hundred => '0-100 MPH', + }; + + String get dbTypeString => switch (this) { + PerformanceLaunchTarget.sixty => '0-60', + PerformanceLaunchTarget.hundred => '0-100', + }; +} + +/// Lifecycle states for Performance Launch Mode. +enum PerformanceLaunchState { + /// Unarmed / idle. + idle, + + /// Stationary validation satisfied; armed and waiting for movement (or countdown trigger). + ready, + + /// Monotonic countdown in progress (3-2-1-GO). + countdown, + + /// Validated movement detected; acceleration timer active. + launched, + + /// Target speed threshold crossed. + targetReached, + + /// Attempt successfully recorded and finalized. + completed, + + /// Attempt aborted or cancelled. + aborted, +} + +/// Reasons for aborting a launch attempt. +enum PerformanceAbortReason { + userCancelled, + gpsLost, + lowConfidence, + vehicleMotionBeforeReady, + stallOrStop, + timeout, + physicalAnomaly, + telemetryRejected, +} + +/// Intermediate milestone split during a launch attempt (e.g. 60 MPH during a 0-100 run). +class PerformanceSplit { + final double targetSpeedMph; + final DateTime timestamp; + final int monotonicTimestampMs; + final double elapsedFromMovementSec; + final double speedReachedMph; + + const PerformanceSplit({ + required this.targetSpeedMph, + required this.timestamp, + required this.monotonicTimestampMs, + required this.elapsedFromMovementSec, + required this.speedReachedMph, + }); +} + +/// Immutable finalized result of a performance launch attempt. +class PerformanceLaunchResult { + final PerformanceLaunchMode mode; + final PerformanceLaunchTarget target; + final bool isFalseStart; + final double? reactionTimeSec; + final double accelerationTimeSec; + final double? totalTimeFromGoSec; + final double topSpeedReachedMph; + final List splits; + final int? rideId; + final int? motorcycleId; + final DateTime timestamp; + final double telemetryConfidence; + + const PerformanceLaunchResult({ + required this.mode, + required this.target, + this.isFalseStart = false, + this.reactionTimeSec, + required this.accelerationTimeSec, + this.totalTimeFromGoSec, + required this.topSpeedReachedMph, + this.splits = const [], + this.rideId, + this.motorcycleId, + required this.timestamp, + this.telemetryConfidence = 1.0, + }); + + /// Convenience getter for 60 MPH split time if present. + double? get split60TimeSec { + for (final s in splits) { + if (s.targetSpeedMph == 60.0) return s.elapsedFromMovementSec; + } + return null; + } +} + +/// Dynamic attempt model representing live or historical launch attempt state. +class PerformanceLaunchAttempt { + final PerformanceLaunchState state; + final PerformanceLaunchMode mode; + final PerformanceLaunchTarget target; + final bool isFalseStart; + final int? armedAtMonotonicMs; + final int? countdownStartedAtMonotonicMs; + final int? scheduledGoAtMonotonicMs; + final int? goAtMonotonicMs; + final int? movementDetectedAtMonotonicMs; + final int? targetReachedAtMonotonicMs; + final DateTime? movementDetectedAtDateTime; + final DateTime? targetReachedAtDateTime; + final double currentSpeedMph; + final double maxSpeedReachedMph; + final double? currentElapsedSeconds; + final List splits; + final PerformanceLaunchResult? lastResult; + final PerformanceAbortReason? abortReason; + final double telemetryConfidence; + final int? rideId; + final int? motorcycleId; + + const PerformanceLaunchAttempt({ + this.state = PerformanceLaunchState.idle, + this.mode = PerformanceLaunchMode.auto, + this.target = PerformanceLaunchTarget.sixty, + this.isFalseStart = false, + this.armedAtMonotonicMs, + this.countdownStartedAtMonotonicMs, + this.scheduledGoAtMonotonicMs, + this.goAtMonotonicMs, + this.movementDetectedAtMonotonicMs, + this.targetReachedAtMonotonicMs, + this.movementDetectedAtDateTime, + this.targetReachedAtDateTime, + this.currentSpeedMph = 0.0, + this.maxSpeedReachedMph = 0.0, + this.currentElapsedSeconds, + this.splits = const [], + this.lastResult, + this.abortReason, + this.telemetryConfidence = 1.0, + this.rideId, + this.motorcycleId, + }); + + PerformanceLaunchAttempt copyWith({ + PerformanceLaunchState? state, + PerformanceLaunchMode? mode, + PerformanceLaunchTarget? target, + bool? isFalseStart, + int? armedAtMonotonicMs, + int? countdownStartedAtMonotonicMs, + int? scheduledGoAtMonotonicMs, + int? goAtMonotonicMs, + int? movementDetectedAtMonotonicMs, + int? targetReachedAtMonotonicMs, + DateTime? movementDetectedAtDateTime, + DateTime? targetReachedAtDateTime, + double? currentSpeedMph, + double? maxSpeedReachedMph, + double? currentElapsedSeconds, + List? splits, + PerformanceLaunchResult? lastResult, + PerformanceAbortReason? abortReason, + double? telemetryConfidence, + int? rideId, + int? motorcycleId, + }) { + return PerformanceLaunchAttempt( + state: state ?? this.state, + mode: mode ?? this.mode, + target: target ?? this.target, + isFalseStart: isFalseStart ?? this.isFalseStart, + armedAtMonotonicMs: armedAtMonotonicMs ?? this.armedAtMonotonicMs, + countdownStartedAtMonotonicMs: countdownStartedAtMonotonicMs ?? this.countdownStartedAtMonotonicMs, + scheduledGoAtMonotonicMs: scheduledGoAtMonotonicMs ?? this.scheduledGoAtMonotonicMs, + goAtMonotonicMs: goAtMonotonicMs ?? this.goAtMonotonicMs, + movementDetectedAtMonotonicMs: movementDetectedAtMonotonicMs ?? this.movementDetectedAtMonotonicMs, + targetReachedAtMonotonicMs: targetReachedAtMonotonicMs ?? this.targetReachedAtMonotonicMs, + movementDetectedAtDateTime: movementDetectedAtDateTime ?? this.movementDetectedAtDateTime, + targetReachedAtDateTime: targetReachedAtDateTime ?? this.targetReachedAtDateTime, + currentSpeedMph: currentSpeedMph ?? this.currentSpeedMph, + maxSpeedReachedMph: maxSpeedReachedMph ?? this.maxSpeedReachedMph, + currentElapsedSeconds: currentElapsedSeconds ?? this.currentElapsedSeconds, + splits: splits ?? this.splits, + lastResult: lastResult ?? this.lastResult, + abortReason: abortReason ?? this.abortReason, + telemetryConfidence: telemetryConfidence ?? this.telemetryConfidence, + rideId: rideId ?? this.rideId, + motorcycleId: motorcycleId ?? this.motorcycleId, + ); + } +} diff --git a/lib/features/performance/performance_providers.dart b/lib/features/performance/performance_providers.dart new file mode 100644 index 0000000..4af3a59 --- /dev/null +++ b/lib/features/performance/performance_providers.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../database/database.dart'; +import '../../services/telemetry_service.dart'; +import '../garage/garage_providers.dart'; +import '../records/records_service.dart'; +import '../rides/ride_state_machine.dart'; +import 'models/performance_launch_models.dart'; +import 'services/monotonic_clock.dart'; +import 'services/performance_launch_state_machine.dart'; + +/// Monotonic clock provider for performance timing domain. +final monotonicClockProvider = Provider((ref) { + return SystemMonotonicClock(); +}); + +/// Authoritative provider for Performance Launch Mode state machine. +final performanceLaunchProvider = + StateNotifierProvider((ref) { + final db = ref.watch(databaseProvider); + final telemetryService = ref.watch(telemetryServiceProvider); + final recordsService = ref.watch(recordsServiceProvider.notifier); + final clock = ref.watch(monotonicClockProvider); + + return PerformanceLaunchStateMachine( + db: db, + telemetryStream: telemetryService.telemetryStream, + recordsService: recordsService, + clock: clock, + activeRideIdReader: () { + final rideState = ref.read(rideStateMachineProvider); + return rideState.state == RideState.riding ? rideState.dbId : null; + }, + motorcycleIdReader: () { + final defaultBike = ref.read(defaultMotorcycleProvider); + return defaultBike?.id; + }, + ); +}); diff --git a/lib/features/performance/services/monotonic_clock.dart b/lib/features/performance/services/monotonic_clock.dart new file mode 100644 index 0000000..89f9108 --- /dev/null +++ b/lib/features/performance/services/monotonic_clock.dart @@ -0,0 +1,70 @@ +/// Authoritative monotonic clock interface for performance timing. +/// +/// Decouples time-delta calculations from system wall-clock mutations +/// (e.g. NTP updates, time zone changes, manual clock edits). +abstract class MonotonicClock { + /// Monotonic elapsed time in milliseconds from clock baseline. + int get elapsedMilliseconds; + + /// Monotonic elapsed time in microseconds from clock baseline. + int get elapsedMicroseconds; + + /// Current wall-clock time for metadata recording only. + DateTime get now; +} + +/// Standard production monotonic clock powered by a high-resolution [Stopwatch]. +class SystemMonotonicClock implements MonotonicClock { + final Stopwatch _stopwatch; + + SystemMonotonicClock([Stopwatch? stopwatch]) + : _stopwatch = stopwatch ?? (Stopwatch()..start()); + + @override + int get elapsedMilliseconds => _stopwatch.elapsedMilliseconds; + + @override + int get elapsedMicroseconds => _stopwatch.elapsedMicroseconds; + + @override + DateTime get now => DateTime.now(); +} + +/// Deterministic fake monotonic clock for hermetic unit testing. +class FakeMonotonicClock implements MonotonicClock { + int _elapsedMilliseconds; + DateTime _wallClockTime; + + FakeMonotonicClock({ + int initialElapsedMs = 0, + DateTime? initialDateTime, + }) : _elapsedMilliseconds = initialElapsedMs, + _wallClockTime = initialDateTime ?? DateTime(2026, 9, 5, 12, 0, 0); + + @override + int get elapsedMilliseconds => _elapsedMilliseconds; + + @override + int get elapsedMicroseconds => _elapsedMilliseconds * 1000; + + @override + DateTime get now => _wallClockTime; + + /// Advance monotonic time and optionally wall clock time. + void advance(Duration duration, {bool advanceWallClock = true}) { + _elapsedMilliseconds += duration.inMilliseconds; + if (advanceWallClock) { + _wallClockTime = _wallClockTime.add(duration); + } + } + + /// Explicitly set elapsed milliseconds. + void setElapsedMs(int ms) { + _elapsedMilliseconds = ms; + } + + /// Simulate a sudden wall-clock jump (e.g. NTP sync / time travel) without affecting monotonic time. + void setWallClock(DateTime time) { + _wallClockTime = time; + } +} diff --git a/lib/features/performance/services/performance_launch_state_machine.dart b/lib/features/performance/services/performance_launch_state_machine.dart new file mode 100644 index 0000000..a1e9b5c --- /dev/null +++ b/lib/features/performance/services/performance_launch_state_machine.dart @@ -0,0 +1,463 @@ +import 'dart:async'; +import 'package:drift/drift.dart' as drift; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../database/database.dart' hide TelemetrySample; +import '../../../models/telemetry_sample.dart'; +import '../../records/records_service.dart'; +import '../models/performance_launch_models.dart'; +import 'monotonic_clock.dart'; + +/// Single authoritative timing engine and state machine for Performance Launch Mode. +/// +/// Consumes MotorSignal's validated 20Hz telemetry stream and enforces monotonic +/// timing, stationary arming gates, countdown scheduling, false-start detection, +/// intermediate split capture (60 MPH split in 0-100 runs), and drift persistence. +class PerformanceLaunchStateMachine extends StateNotifier { + final AppDatabase db; + final RecordsService? recordsService; + final MonotonicClock clock; + final int? Function()? activeRideIdReader; + final int? Function()? motorcycleIdReader; + final int countdownDurationMs; + + StreamSubscription? _telemetrySub; + TelemetrySample? _lastSample; + int _lastMonotonicMs = 0; + int _lastSequenceNumber = 0; + + static const double _movementThresholdMph = 1.0; + static const double _stationaryThresholdMph = 1.0; + static const double _abortStopThresholdMph = 0.5; + + PerformanceLaunchStateMachine({ + required this.db, + required Stream telemetryStream, + this.recordsService, + MonotonicClock? clock, + this.activeRideIdReader, + this.motorcycleIdReader, + this.countdownDurationMs = 3000, + }) : clock = clock ?? SystemMonotonicClock(), + super(const PerformanceLaunchAttempt()) { + _telemetrySub = telemetryStream.listen(_onTelemetryUpdate); + } + + /// Arm Performance Launch Mode with chosen mode and target. + /// + /// Rejects concurrent arm requests if already actively timing in [PerformanceLaunchState.launched] + /// or [PerformanceLaunchState.countdown]. + void arm({ + PerformanceLaunchMode mode = PerformanceLaunchMode.auto, + PerformanceLaunchTarget target = PerformanceLaunchTarget.sixty, + }) { + if (state.state == PerformanceLaunchState.launched || + state.state == PerformanceLaunchState.countdown) { + // Cannot re-arm while timing or in countdown + return; + } + + final armedMs = clock.elapsedMilliseconds; + final rideId = activeRideIdReader?.call(); + final motorcycleId = motorcycleIdReader?.call(); + + final isStationary = _lastSample != null && + (_lastSample!.speed ?? 0.0) < _stationaryThresholdMph && + (_lastSample!.quality?.isValid ?? true); + + state = PerformanceLaunchAttempt( + state: isStationary ? PerformanceLaunchState.ready : PerformanceLaunchState.idle, + mode: mode, + target: target, + armedAtMonotonicMs: armedMs, + rideId: rideId, + motorcycleId: motorcycleId, + ); + } + + /// Start the monotonic countdown (3-2-1-GO) for Countdown mode. + /// + /// Requires state to be in [PerformanceLaunchState.ready]. + void startCountdown() { + if (state.mode != PerformanceLaunchMode.countdown || + state.state != PerformanceLaunchState.ready) { + return; + } + + final nowMs = clock.elapsedMilliseconds; + final scheduledGoMs = nowMs + countdownDurationMs; + + state = state.copyWith( + state: PerformanceLaunchState.countdown, + countdownStartedAtMonotonicMs: nowMs, + scheduledGoAtMonotonicMs: scheduledGoMs, + ); + } + + /// Cancel the current attempt manually. + void cancel() { + if (state.state == PerformanceLaunchState.idle || + state.state == PerformanceLaunchState.completed) { + return; + } + + state = state.copyWith( + state: PerformanceLaunchState.aborted, + abortReason: PerformanceAbortReason.userCancelled, + ); + } + + /// Reset to idle state. + void reset() { + state = const PerformanceLaunchAttempt(); + _lastSample = null; + _lastMonotonicMs = 0; + _lastSequenceNumber = 0; + } + + void _onTelemetryUpdate(TelemetrySample sample) { + if (sample.speed == null) return; + + // 1. Reject invalid / corrupted samples + if (sample.quality != null && sample.quality!.isRejected) { + if (state.state == PerformanceLaunchState.launched) { + state = state.copyWith( + state: PerformanceLaunchState.aborted, + abortReason: PerformanceAbortReason.telemetryRejected, + ); + } + 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; + + if (_lastMonotonicMs > 0 && sampleMonotonicMs < _lastMonotonicMs) { + // Out of order sample discarded + return; + } + if (_lastSequenceNumber > 0 && sampleSeq > 0 && sampleSeq <= _lastSequenceNumber) { + // Duplicate or back-tracked sequence discarded + return; + } + + _lastMonotonicMs = sampleMonotonicMs; + if (sampleSeq > 0) _lastSequenceNumber = sampleSeq; + + final speedMph = sample.speed!; + final confidence = sample.quality?.overallConfidence ?? 1.0; + + // 3. State Machine Transitions + switch (state.state) { + case PerformanceLaunchState.idle: + // Automatically become ready if stationary validation passes and armed + if (state.armedAtMonotonicMs != null && speedMph < _stationaryThresholdMph && confidence >= 0.60) { + state = state.copyWith( + state: PerformanceLaunchState.ready, + telemetryConfidence: confidence, + currentSpeedMph: speedMph, + ); + } else { + state = state.copyWith(currentSpeedMph: speedMph, telemetryConfidence: confidence); + } + break; + + case PerformanceLaunchState.ready: + if (speedMph < _movementThresholdMph) { + state = state.copyWith(currentSpeedMph: speedMph, telemetryConfidence: confidence); + } else if (speedMph >= _movementThresholdMph) { + // Movement detected from ready! + if (state.mode == PerformanceLaunchMode.auto) { + // Auto Launch begins + final movementMs = _interpolateMonotonic( + _lastSample?.speed ?? 0.0, + speedMph, + _lastSample?.monotonicTimestampMs ?? (_lastMonotonicMs - 50), + sampleMonotonicMs, + _movementThresholdMph, + ); + final movementDt = _interpolateDateTime( + _lastSample?.speed ?? 0.0, + speedMph, + _lastSample?.timestamp ?? sample.timestamp.subtract(const Duration(milliseconds: 50)), + sample.timestamp, + _movementThresholdMph, + ); + + state = state.copyWith( + state: PerformanceLaunchState.launched, + movementDetectedAtMonotonicMs: movementMs, + movementDetectedAtDateTime: movementDt, + currentSpeedMph: speedMph, + maxSpeedReachedMph: speedMph, + currentElapsedSeconds: 0.0, + telemetryConfidence: confidence, + ); + } else { + // Countdown mode moved before startCountdown was triggered + // Revert to idle / require stationary + state = state.copyWith( + state: PerformanceLaunchState.idle, + currentSpeedMph: speedMph, + telemetryConfidence: confidence, + ); + } + } + break; + + case PerformanceLaunchState.countdown: + final scheduledGo = state.scheduledGoAtMonotonicMs ?? sampleMonotonicMs; + + if (speedMph >= _movementThresholdMph) { + // Vehicle moved during countdown! + final movementMs = _interpolateMonotonic( + _lastSample?.speed ?? 0.0, + speedMph, + _lastSample?.monotonicTimestampMs ?? (_lastMonotonicMs - 50), + sampleMonotonicMs, + _movementThresholdMph, + ); + final movementDt = _interpolateDateTime( + _lastSample?.speed ?? 0.0, + speedMph, + _lastSample?.timestamp ?? sample.timestamp.subtract(const Duration(milliseconds: 50)), + sample.timestamp, + _movementThresholdMph, + ); + + final isFalseStart = movementMs < scheduledGo; + + state = state.copyWith( + state: PerformanceLaunchState.launched, + isFalseStart: isFalseStart, + goAtMonotonicMs: isFalseStart ? null : scheduledGo, + movementDetectedAtMonotonicMs: movementMs, + movementDetectedAtDateTime: movementDt, + currentSpeedMph: speedMph, + maxSpeedReachedMph: speedMph, + currentElapsedSeconds: 0.0, + telemetryConfidence: confidence, + ); + } else { + // Vehicle stationary during countdown + if (sampleMonotonicMs >= scheduledGo && state.goAtMonotonicMs == null) { + // GO arrived! Awaiting movement + state = state.copyWith( + goAtMonotonicMs: scheduledGo, + currentSpeedMph: speedMph, + telemetryConfidence: confidence, + ); + } else { + state = state.copyWith( + currentSpeedMph: speedMph, + telemetryConfidence: confidence, + ); + } + } + break; + + case PerformanceLaunchState.launched: + final movementMs = state.movementDetectedAtMonotonicMs; + if (movementMs == null) return; + + final elapsedMs = sampleMonotonicMs - movementMs; + final elapsedSec = elapsedMs / 1000.0; + final newMaxSpeed = speedMph > state.maxSpeedReachedMph ? speedMph : state.maxSpeedReachedMph; + + // 1. Check intermediate 60 MPH split for 100 MPH target + List updatedSplits = List.of(state.splits); + if (state.target == PerformanceLaunchTarget.hundred && + updatedSplits.every((s) => s.targetSpeedMph != 60.0)) { + final prevSpeed = _lastSample?.speed ?? 0.0; + if (prevSpeed < 60.0 && speedMph >= 60.0) { + final splitMs = _interpolateMonotonic( + prevSpeed, + speedMph, + _lastSample?.monotonicTimestampMs ?? (sampleMonotonicMs - 50), + sampleMonotonicMs, + 60.0, + ); + final splitDt = _interpolateDateTime( + prevSpeed, + speedMph, + _lastSample?.timestamp ?? sample.timestamp.subtract(const Duration(milliseconds: 50)), + sample.timestamp, + 60.0, + ); + final splitElapsedSec = (splitMs - movementMs) / 1000.0; + + updatedSplits.add(PerformanceSplit( + targetSpeedMph: 60.0, + timestamp: splitDt, + monotonicTimestampMs: splitMs, + elapsedFromMovementSec: splitElapsedSec, + speedReachedMph: 60.0, + )); + } + } + + final targetMph = state.target.targetSpeedMph; + final prevSpeed = _lastSample?.speed ?? 0.0; + + if (speedMph >= targetMph && prevSpeed < targetMph) { + // Target speed reached! + final targetMs = _interpolateMonotonic( + prevSpeed, + speedMph, + _lastSample?.monotonicTimestampMs ?? (sampleMonotonicMs - 50), + sampleMonotonicMs, + targetMph, + ); + final targetDt = _interpolateDateTime( + prevSpeed, + speedMph, + _lastSample?.timestamp ?? sample.timestamp.subtract(const Duration(milliseconds: 50)), + sample.timestamp, + targetMph, + ); + + final preciseAccelerationSec = (targetMs - movementMs) / 1000.0; + + // Physical plausibility sanity check: 0-60 >= 1.5s, 0-100 >= 3.0s + final minPlausibleSec = state.target == PerformanceLaunchTarget.sixty ? 1.5 : 3.0; + + if (preciseAccelerationSec >= minPlausibleSec) { + double? reactionSec; + double? totalFromGoSec; + + if (state.mode == PerformanceLaunchMode.countdown) { + final scheduledGo = state.scheduledGoAtMonotonicMs; + if (scheduledGo != null) { + reactionSec = (movementMs - scheduledGo) / 1000.0; + totalFromGoSec = (targetMs - scheduledGo) / 1000.0; + } + } + + final result = PerformanceLaunchResult( + mode: state.mode, + target: state.target, + isFalseStart: state.isFalseStart, + reactionTimeSec: reactionSec, + accelerationTimeSec: preciseAccelerationSec, + totalTimeFromGoSec: totalFromGoSec, + topSpeedReachedMph: newMaxSpeed, + splits: updatedSplits, + rideId: state.rideId, + motorcycleId: state.motorcycleId, + timestamp: targetDt, + telemetryConfidence: confidence, + ); + + // Persist run + _persistRun(result, (preciseAccelerationSec * 1000.0).round()); + + // Save clean PR if non-false-start and confidence >= 0.60 + if (!state.isFalseStart && confidence >= 0.60) { + _updatePersonalRecords(result); + } + + state = state.copyWith( + state: PerformanceLaunchState.completed, + targetReachedAtMonotonicMs: targetMs, + targetReachedAtDateTime: targetDt, + currentSpeedMph: speedMph, + maxSpeedReachedMph: newMaxSpeed, + currentElapsedSeconds: preciseAccelerationSec, + splits: updatedSplits, + lastResult: result, + telemetryConfidence: confidence, + ); + } else { + // Implausible jump -> abort as physical anomaly + state = state.copyWith( + state: PerformanceLaunchState.aborted, + abortReason: PerformanceAbortReason.physicalAnomaly, + currentElapsedSeconds: preciseAccelerationSec, + ); + } + } else if (speedMph <= _abortStopThresholdMph && elapsedSec > 2.0) { + // Came to a complete stop without crossing target + state = state.copyWith( + state: PerformanceLaunchState.aborted, + abortReason: PerformanceAbortReason.stallOrStop, + currentElapsedSeconds: elapsedSec, + splits: updatedSplits, + ); + } else { + // Still accelerating towards target + state = state.copyWith( + currentSpeedMph: speedMph, + maxSpeedReachedMph: newMaxSpeed, + currentElapsedSeconds: elapsedSec, + splits: updatedSplits, + telemetryConfidence: confidence, + ); + } + break; + + case PerformanceLaunchState.targetReached: + case PerformanceLaunchState.completed: + case PerformanceLaunchState.aborted: + // Terminal states until reset / re-arm + break; + } + + _lastSample = sample; + } + + int _interpolateMonotonic(double v1, double v2, int t1, int t2, double targetV) { + if (v2 <= v1 || t2 <= t1) return t2; + final fraction = ((targetV - v1) / (v2 - v1)).clamp(0.0, 1.0); + final dt = t2 - t1; + return t1 + (dt * fraction).round(); + } + + DateTime _interpolateDateTime(double v1, double v2, DateTime t1, DateTime t2, double targetV) { + if (v2 <= v1 || t2.isBefore(t1)) return t2; + final fraction = ((targetV - v1) / (v2 - v1)).clamp(0.0, 1.0); + final dtMs = t2.difference(t1).inMilliseconds; + return t1.add(Duration(milliseconds: (dtMs * fraction).round())); + } + + Future _persistRun(PerformanceLaunchResult result, int timeMs) async { + try { + await db.addPerformanceRun( + PerformanceRunsCompanion.insert( + rideId: drift.Value(result.rideId), + type: result.target.dbTypeString, + timeMs: timeMs.toDouble(), + topSpeedReached: result.topSpeedReachedMph, + timestamp: result.timestamp, + ), + ); + } catch (_) {} + } + + void _updatePersonalRecords(PerformanceLaunchResult result) { + if (recordsService == null) return; + + final stats = switch (result.target) { + PerformanceLaunchTarget.sixty => PersonalRecords( + fastest0To60: result.accelerationTimeSec, + topSpeedMph: result.topSpeedReachedMph, + ), + PerformanceLaunchTarget.hundred => PersonalRecords( + fastest0To100: result.accelerationTimeSec, + fastest0To60: result.split60TimeSec ?? double.infinity, + topSpeedMph: result.topSpeedReachedMph, + ), + }; + + recordsService!.checkAndSaveRecords( + stats, + null, + confidence: result.telemetryConfidence, + ); + } + + @override + void dispose() { + _telemetrySub?.cancel(); + super.dispose(); + } +} diff --git a/test/performance_launch_mode_test.dart b/test/performance_launch_mode_test.dart new file mode 100644 index 0000000..85339bc --- /dev/null +++ b/test/performance_launch_mode_test.dart @@ -0,0 +1,631 @@ +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 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.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.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.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.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.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.delayed(const Duration(milliseconds: 10)); + + // Launch at t=1050ms + telemetryController.add(makeSample(speed: 4.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + + // Mid run at t=2500ms (speed 45) + telemetryController.add(makeSample(speed: 45.0, monotonicMs: 2500)); + await Future.delayed(const Duration(milliseconds: 10)); + + // Cross 60 MPH at t=3800ms (speed 62.0) + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3800)); + await Future.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.delayed(const Duration(milliseconds: 10)); + + // Launch at t=1050ms + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + + // 60 MPH split crossing at t=3500ms + telemetryController.add(makeSample(speed: 63.0, monotonicMs: 3500)); + await Future.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.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500)); + await Future.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.delayed(const Duration(milliseconds: 10)); + + telemetryController.add(makeSample(speed: 10.0, monotonicMs: 1100, seq: 11)); + await Future.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.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050, seq: 2)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500, seq: 3)); + await Future.delayed(const Duration(milliseconds: 10)); + + // Duplicate sample replay with same seq + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500, seq: 3)); + await Future.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.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 20.0, monotonicMs: 1200)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 20.0, monotonicMs: 1200)); + await Future.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.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 25.0, monotonicMs: 1200)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3500)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 61.0, monotonicMs: 3500)); + await Future.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.delayed(const Duration(milliseconds: 10)); + telemetryController.add(makeSample(speed: 5.0, monotonicMs: 1050)); + await Future.delayed(const Duration(milliseconds: 10)); + + // 60 MPH intermediate crossing + telemetryController.add(makeSample(speed: 62.0, monotonicMs: 3600)); + await Future.delayed(const Duration(milliseconds: 10)); + + // 100 MPH target crossing + telemetryController.add(makeSample(speed: 101.0, monotonicMs: 6800)); + await Future.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!)); + }); + }); +}