feat: add performance launch timing foundation

This commit is contained in:
Developer 2026-09-05 08:11:21 -04:00
parent a7cce30e3d
commit 8b176293c3
5 changed files with 1425 additions and 0 deletions

View File

@ -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<PerformanceSplit> 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<PerformanceSplit> 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<PerformanceSplit>? 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,
);
}
}

View File

@ -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<MonotonicClock>((ref) {
return SystemMonotonicClock();
});
/// Authoritative provider for Performance Launch Mode state machine.
final performanceLaunchProvider =
StateNotifierProvider<PerformanceLaunchStateMachine, PerformanceLaunchAttempt>((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;
},
);
});

View File

@ -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;
}
}

View File

@ -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<PerformanceLaunchAttempt> {
final AppDatabase db;
final RecordsService? recordsService;
final MonotonicClock clock;
final int? Function()? activeRideIdReader;
final int? Function()? motorcycleIdReader;
final int countdownDurationMs;
StreamSubscription<TelemetrySample>? _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<TelemetrySample> 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<PerformanceSplit> 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<void> _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();
}
}

View File

@ -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<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!));
});
});
}