feat: add performance launch timing foundation
This commit is contained in:
parent
a7cce30e3d
commit
8b176293c3
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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!));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue