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