diff --git a/lib/features/performance/performance_timer_service.dart b/lib/features/performance/performance_timer_service.dart index 1f4f3a4..5682b0c 100644 --- a/lib/features/performance/performance_timer_service.dart +++ b/lib/features/performance/performance_timer_service.dart @@ -1,8 +1,10 @@ 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 '../../services/telemetry_service.dart'; +import '../rides/ride_state_machine.dart'; enum PerformanceState { idle, ready, timing, completed, aborted } enum PerformanceTarget { sixty, hundred } @@ -49,20 +51,27 @@ class PerformanceStateData { class PerformanceTimerService extends StateNotifier { final AppDatabase _db; + final int? Function()? activeRideIdReader; StreamSubscription? _telemetrySub; TelemetrySample? _lastSample; DateTime? _startTime; + int? _rideIdAtAttemptStart; double _maxSpeedMph = 0.0; static const double _speedThresholdMph = 1.0; static const double _abortThresholdMph = 0.5; - PerformanceTimerService(this._db, Stream telemetryStream) : super(PerformanceStateData()) { + PerformanceTimerService( + this._db, + Stream telemetryStream, { + this.activeRideIdReader, + }) : super(PerformanceStateData()) { _telemetrySub = telemetryStream.listen(_onTelemetryUpdate); } void setTarget(PerformanceTarget target) { + _rideIdAtAttemptStart = null; state = state.copyWith(target: target, state: PerformanceState.ready); } @@ -88,6 +97,7 @@ class PerformanceTimerService extends StateNotifier { sample.timestamp, _speedThresholdMph ); + _rideIdAtAttemptStart = activeRideIdReader?.call(); _maxSpeedMph = speedMph; state = state.copyWith(state: PerformanceState.timing, currentElapsedSeconds: 0.0); } @@ -162,6 +172,7 @@ class PerformanceTimerService extends StateNotifier { Future _saveRun(PerformanceRunResult result, int timeMs) async { final typeStr = result.target == PerformanceTarget.sixty ? '0-60' : '0-100'; await _db.addPerformanceRun(PerformanceRunsCompanion.insert( + rideId: drift.Value(_rideIdAtAttemptStart), type: typeStr, timeMs: timeMs.toDouble(), topSpeedReached: result.topSpeedReachedMph, @@ -179,5 +190,12 @@ class PerformanceTimerService extends StateNotifier { final performanceTimerProvider = StateNotifierProvider((ref) { final db = ref.watch(databaseProvider); final telemetryService = ref.watch(telemetryServiceProvider); - return PerformanceTimerService(db, telemetryService.telemetryStream); + return PerformanceTimerService( + db, + telemetryService.telemetryStream, + activeRideIdReader: () { + final rideState = ref.read(rideStateMachineProvider); + return rideState.state == RideState.riding ? rideState.dbId : null; + }, + ); }); diff --git a/test/performance_ride_association_test.dart b/test/performance_ride_association_test.dart new file mode 100644 index 0000000..e747c0c --- /dev/null +++ b/test/performance_ride_association_test.dart @@ -0,0 +1,112 @@ +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/performance_timer_service.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late StreamController controller; + PerformanceTimerService? timer; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + controller = StreamController.broadcast(); + }); + + tearDown(() async { + timer?.dispose(); + await controller.close(); + await db.close(); + }); + + Future completeZeroToSixty(DateTime base) async { + controller.add(TelemetrySample(timestamp: base, speed: 0.0)); + await Future.delayed(const Duration(milliseconds: 10)); + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 200)), speed: 5.0)); + await Future.delayed(const Duration(milliseconds: 10)); + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 1800)), speed: 45.0)); + await Future.delayed(const Duration(milliseconds: 10)); + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 2800)), speed: 62.0)); + await Future.delayed(const Duration(milliseconds: 50)); + } + + test('performance run completed during active ride stores that ride ID', () async { + final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 3, 12)); + timer = PerformanceTimerService( + db, + controller.stream, + activeRideIdReader: () => rideId, + ); + + timer!.setTarget(PerformanceTarget.sixty); + await completeZeroToSixty(DateTime.utc(2026, 9, 3, 12, 1)); + + final runs = await db.select(db.performanceRuns).get(); + expect(runs, hasLength(1)); + expect(runs.single.rideId, rideId); + }); + + test('ride-specific performance query returns associated run after ride completion', () async { + final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 3, 13)); + timer = PerformanceTimerService( + db, + controller.stream, + activeRideIdReader: () => rideId, + ); + + timer!.setTarget(PerformanceTarget.sixty); + await completeZeroToSixty(DateTime.utc(2026, 9, 3, 13, 1)); + await db.endRide(rideId, const RidesCompanion()); + + final rideRuns = await (db.select(db.performanceRuns)..where((p) => p.rideId.equals(rideId))).get(); + expect(rideRuns, hasLength(1)); + expect(rideRuns.single.type, '0-60'); + expect(rideRuns.single.rideId, rideId); + }); + + test('standalone performance run remains valid without ride ID', () async { + timer = PerformanceTimerService(db, controller.stream); + + timer!.setTarget(PerformanceTarget.sixty); + await completeZeroToSixty(DateTime.utc(2026, 9, 3, 14)); + + final runs = await db.select(db.performanceRuns).get(); + expect(runs, hasLength(1)); + expect(runs.single.rideId, isNull); + }); + + test('attempt that starts on ride A is not reassigned to ride B before save', () async { + final rideA = await db.startRide(startTime: DateTime.utc(2026, 9, 3, 15)); + final rideB = await db.startRide(startTime: DateTime.utc(2026, 9, 3, 16)); + var activeRideId = rideA; + timer = PerformanceTimerService( + db, + controller.stream, + activeRideIdReader: () => activeRideId, + ); + + final base = DateTime.utc(2026, 9, 3, 15, 1); + timer!.setTarget(PerformanceTarget.sixty); + controller.add(TelemetrySample(timestamp: base, speed: 0.0)); + await Future.delayed(const Duration(milliseconds: 10)); + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 200)), speed: 5.0)); + await Future.delayed(const Duration(milliseconds: 10)); + + activeRideId = rideB; + + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 1800)), speed: 45.0)); + await Future.delayed(const Duration(milliseconds: 10)); + controller.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 2800)), speed: 62.0)); + await Future.delayed(const Duration(milliseconds: 50)); + + final rideARuns = await (db.select(db.performanceRuns)..where((p) => p.rideId.equals(rideA))).get(); + final rideBRuns = await (db.select(db.performanceRuns)..where((p) => p.rideId.equals(rideB))).get(); + expect(rideARuns, hasLength(1)); + expect(rideBRuns, isEmpty); + }); +}