import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:drift/native.dart'; import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/models/recording_health_status.dart'; import 'package:motorcycle_dash/services/telemetry_service.dart'; import 'package:motorcycle_dash/services/location_service.dart'; import 'package:motorcycle_dash/services/sensor_fusion_service.dart'; import 'package:motorcycle_dash/services/compass_service.dart'; import 'package:motorcycle_dash/services/audio_alert_service.dart'; import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/garage/garage_providers.dart'; class MockAudioAlertService extends Fake implements AudioAlertService { final List spokenMessages = []; @override Future speak(String text, {bool force = false}) async { spokenMessages.add(text); } } class FakeTelemetryService extends TelemetryService { final StreamController _controller = StreamController.broadcast(); FakeTelemetryService( super.loc, super.fusion, super.compass, super.logger, super.validator, ); @override Stream get telemetryStream => _controller.stream; void emitSample(TelemetrySample sample) { _controller.add(sample); } @override void dispose() { _controller.close(); } } class _NullLocationService extends Fake implements LocationService { @override void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic; @override Stream getFilteredPositionStream() => const Stream.empty(); } class _NullSensorFusionService extends Fake implements SensorFusionService { @override Stream get leanAngleStream => const Stream.empty(); @override Stream get pitchAngleStream => const Stream.empty(); @override Stream get gForceStream => const Stream.empty(); @override double get latestRollRateDegPerSec => 0.0; @override double get latestRawAccelX => 0.0; @override double get latestRawAccelY => 0.0; @override double get latestRawAccelZ => 0.0; @override double get latestRawGyroX => 0.0; @override double get latestRawGyroY => 0.0; @override double get latestRawGyroZ => 0.0; } class _NullCompassService extends Fake implements CompassService { @override Stream get headingStream => const Stream.empty(); } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late FakeTelemetryService fakeTelemetry; late MockAudioAlertService mockAudio; late FieldTestLogger logger; late TelemetryValidator validator; late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = AppDatabase.forTesting(NativeDatabase.memory()); logger = FieldTestLogger(db); validator = TelemetryValidator(); fakeTelemetry = FakeTelemetryService( _NullLocationService(), _NullSensorFusionService(), _NullCompassService(), logger, validator, ); mockAudio = MockAudioAlertService(); }); tearDown(() async { fakeTelemetry.dispose(); await db.close(); }); test('Physical Failure Regression: Starting ride creates DB session, warm-up health, and logs lifecycle', () async { final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), databaseProvider.overrideWithValue(db), telemetryServiceProvider.overrideWithValue(fakeTelemetry), audioAlertServiceProvider.overrideWithValue(mockAudio), fieldTestLoggerProvider.overrideWithValue(logger), ], ); addTearDown(container.dispose); // Initial state: Idle var session = container.read(rideStateMachineProvider); expect(session.state, equals(RideState.idle)); expect(session.dbId, isNull); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); // Verify state transitioned to RIDING and received a valid DB ID! session = container.read(rideStateMachineProvider); expect(session.state, equals(RideState.riding)); expect(session.dbId, isNotNull); // RID-3C-1: a freshly created ride has NO evidence yet, so it must report // warm-up health instead of falsely claiming an active recording. Health // promotes automatically once real GPS / IMU / telemetry / DB-commit evidence // arrives; see test/rid3c1_truthful_recording_health_test.dart (T1, T2). expect(session.healthStatus, equals(RecordingHealthStatus.recordingArmed)); expect(session.health.canReportRecording, isFalse); expect(session.health.isValidSessionId, isTrue); // Emit 10 telemetry samples for (int i = 0; i < 10; i++) { fakeTelemetry.emitSample( TelemetrySample( timestamp: DateTime.now(), latitude: 34.2570 + (i * 0.001), longitude: -85.1647 + (i * 0.001), speed: 45.0 + i, heading: 90.0, leanAngle: 12.0, longitudinalG: 0.2, lateralG: 0.1, gpsAccuracy: 4.0, ), ); } await Future.delayed(const Duration(milliseconds: 100)); // Stop session await notifier.finishRide(); await logger.flush(); // Verify ride in SQLite database is non-empty and properly finalized final rides = await db.select(db.rides).get(); expect(rides.length, equals(1)); expect(rides.first.endTime, isNotNull); expect(rides.first.topSpeedMph, greaterThanOrEqualTo(45.0)); // Verify field test events captured lifecycle final events = await db.select(db.fieldTestEvents).get(); // print('Captured events: ${events.map((e) => e.eventType).toList()}'); expect(events.isNotEmpty, isTrue); expect(events.any((e) => e.eventType.contains('START') || e.eventType.contains('RIDE')), isTrue); }); test('RideStateMachine assigns stable cloud identity at ride creation', () async { final bike = GarageMotorcycle( id: 7, year: 2024, makeId: 'triumph', makeName: 'Triumph', modelId: 'street-triple', modelName: 'Street Triple', variantName: 'RS', isDefault: true, dateAdded: DateTime.utc(2026, 9, 1), lastUpdated: DateTime.utc(2026, 9, 1), ); final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), databaseProvider.overrideWithValue(db), telemetryServiceProvider.overrideWithValue(fakeTelemetry), audioAlertServiceProvider.overrideWithValue(mockAudio), fieldTestLoggerProvider.overrideWithValue(logger), defaultMotorcycleProvider.overrideWithValue(bike), ], ); addTearDown(container.dispose); final notifier = container.read(rideStateMachineProvider.notifier); await notifier.startRide(); final session = container.read(rideStateMachineProvider); expect(session.dbId, isNotNull); final ride = await (db.select(db.rides)..where((r) => r.id.equals(session.dbId!))).getSingle(); expect(ride.cloudRideId, isNotNull); expect(ride.cloudRideId, startsWith('ride_')); expect(ride.motorcycleId, bike.id); expect(ride.motorcycleYear, bike.year); expect(ride.motorcycleMake, bike.makeName); expect(ride.motorcycleModel, bike.modelName); expect(ride.motorcycleVariant, bike.variantName); final ensured = await db.ensureCloudRideId(ride.id); expect(ensured, ride.cloudRideId); expect(await db.getCloudRideId(ride.id), ride.cloudRideId); }); test('Interrupted ride recovery preserves existing cloud identity', () async { final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 2, 12)); final cloudRideId = await db.getCloudRideId(rideId); expect(cloudRideId, isNotNull); await db.into(db.telemetrySamples).insert( TelemetrySamplesCompanion.insert( rideId: rideId, timestamp: DateTime.utc(2026, 9, 2, 12, 5), ), ); final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), databaseProvider.overrideWithValue(db), telemetryServiceProvider.overrideWithValue(fakeTelemetry), audioAlertServiceProvider.overrideWithValue(mockAudio), fieldTestLoggerProvider.overrideWithValue(logger), ], ); addTearDown(container.dispose); container.read(rideStateMachineProvider.notifier); await Future.delayed(const Duration(milliseconds: 50)); final recovered = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle(); expect( recovered.endTime!.isAtSameMomentAs(DateTime.utc(2026, 9, 2, 12, 5)), isTrue, ); expect(recovered.cloudRideId, cloudRideId); expect(await db.ensureCloudRideId(rideId), cloudRideId); }); test('Motion Safeguard: Moving vehicle auto-promotes READY state to active recording', () async { final container = ProviderContainer( overrides: [ sharedPreferencesProvider.overrideWithValue(prefs), databaseProvider.overrideWithValue(db), telemetryServiceProvider.overrideWithValue(fakeTelemetry), audioAlertServiceProvider.overrideWithValue(mockAudio), fieldTestLoggerProvider.overrideWithValue(logger), ], ); addTearDown(container.dispose); final notifier = container.read(rideStateMachineProvider.notifier); notifier.setReady(); var session = container.read(rideStateMachineProvider); expect(session.state, equals(RideState.ready)); expect(session.dbId, isNull); // Emit movement sample (speed = 25 MPH) fakeTelemetry.emitSample( TelemetrySample( timestamp: DateTime.now(), latitude: 34.2570, longitude: -85.1647, speed: 25.0, heading: 90.0, gpsAccuracy: 3.0, ), ); await Future.delayed(const Duration(milliseconds: 50)); // Verify auto-start promoted to RIDING with assigned dbId session = container.read(rideStateMachineProvider); expect(session.state, equals(RideState.riding)); expect(session.dbId, isNotNull); }); test('Recording Health Contract validates prerequisites strictly', () { const armed = RecordingHealthContract( status: RecordingHealthStatus.recordingArmed, isForegroundServiceAlive: false, isTelemetryPipelineActive: false, ); expect(armed.canReportRecording, isFalse); const active = RecordingHealthContract( status: RecordingHealthStatus.recordingActive, isForegroundServiceAlive: true, isTelemetryPipelineActive: true, isAtLeastOneSensorActive: true, isDatabasePersistenceOperational: true, isValidSessionId: true, ); expect(active.canReportRecording, isTrue); }); }