import 'package:flutter_test/flutter_test.dart'; import 'package:drift/native.dart'; import 'package:drift/drift.dart' as drift; import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample; import 'package:motorcycle_dash/models/field_test_event.dart'; import 'package:motorcycle_dash/models/telemetry_sample.dart'; import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart'; import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late FieldTestLogger logger; late FieldTestExportService exportService; setUp(() async { SharedPreferences.setMockInitialValues({}); db = AppDatabase.forTesting(NativeDatabase.memory()); logger = FieldTestLogger(db); exportService = FieldTestExportService(db); }); tearDown(() async { logger.dispose(); await db.close(); }); group('Phase 10: Forensic Event Logging & De-duplication', () { test('Logs GPS stale event on initial timeout, then single GPS recovery event', () async { logger.startSession(1); final validator = TelemetryValidator(); final startTime = DateTime(2026, 8, 28, 12, 0, 0); // 1. Send normal samples for (int i = 0; i < 5; i++) { final sample = TelemetrySample( timestamp: startTime.add(Duration(milliseconds: i * 50)), latitude: 37.7749, longitude: -122.4194, speed: 45.0, gpsAccuracy: 3.0, gpsAgeMs: 50, ); final q = validator.validate(sample); logger.processTelemetrySample(sample, q); } // 2. Send 10 consecutive stale samples (gpsAgeMs = 2500ms, moving at 45 MPH) for (int i = 5; i < 15; i++) { final sample = TelemetrySample( timestamp: startTime.add(Duration(milliseconds: i * 50)), latitude: 37.7749, longitude: -122.4194, speed: 45.0, gpsAccuracy: 12.0, gpsAgeMs: 2500, ); final q = validator.validate(sample); logger.processTelemetrySample(sample, q); } // 3. Verify exactly ONE gpsStale event was created (de-duplicated) final staleEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.gpsStale).toList(); expect(staleEvents.length, 1); expect(staleEvents.first.severity, EventSeverity.warning); expect(staleEvents.first.gpsAgeMs, 2500); // 4. Send recovered sample (gpsAgeMs = 50ms) final recoveredSample = TelemetrySample( timestamp: startTime.add(const Duration(milliseconds: 15 * 50)), latitude: 37.7750, longitude: -122.4194, speed: 45.0, gpsAccuracy: 3.2, gpsAgeMs: 50, ); final qRecovered = validator.validate(recoveredSample); logger.processTelemetrySample(recoveredSample, qRecovered); // 5. Verify gpsRecovered event was created with metadata final recoveredEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.gpsRecovered).toList(); expect(recoveredEvents.length, 1); expect(recoveredEvents.first.severity, EventSeverity.info); expect(recoveredEvents.first.metadata?['maxGpsAgeMs'], 2500); }); test('De-duplicates sustained IMU saturation (>120 m/s^2)', () async { logger.startSession(2); final validator = TelemetryValidator(); final startTime = DateTime(2026, 8, 28, 12, 0, 0); // Send 8 saturated samples in a row for (int i = 0; i < 8; i++) { final sample = TelemetrySample( timestamp: startTime.add(Duration(milliseconds: i * 50)), speed: 35.0, accelX: 135.0, // > 120 saturation limit accelY: 0.0, accelZ: 9.8, ); final q = validator.validate(sample); logger.processTelemetrySample(sample, q); } // Verify only 1 IMU_SATURATION event was emitted final satEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.imuSaturation).toList(); expect(satEvents.length, 1); // Return to normal final normalSample = TelemetrySample( timestamp: startTime.add(const Duration(milliseconds: 500)), speed: 35.0, accelX: 2.0, accelY: 0.0, accelZ: 9.8, ); final qNorm = validator.validate(normalSample); logger.processTelemetrySample(normalSample, qNorm); final recEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.imuRecovered).toList(); expect(recEvents.length, 1); }); test('Logs significant acceleration and braking events', () async { logger.startSession(3); final validator = TelemetryValidator(); final time = DateTime(2026, 8, 28, 12, 0, 0); // Acceleration: 0.78g final accelSample = TelemetrySample( timestamp: time, speed: 40.0, longitudinalG: 0.78, ); logger.processTelemetrySample(accelSample, validator.validate(accelSample)); // Braking: -0.92g final brakeSample = TelemetrySample( timestamp: time.add(const Duration(seconds: 10)), speed: 25.0, longitudinalG: -0.92, ); logger.processTelemetrySample(brakeSample, validator.validate(brakeSample)); expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.accelerationEvent), isTrue); expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.brakingEvent), isTrue); }); }); group('Phase 10: Database Persistence & Crash Recovery', () { test('Batch flushes events to SQLite with chronological ordering and ride filtering', () async { final now = DateTime(2026, 8, 28, 14, 0, 0); logger.startSession(42, timestamp: now.subtract(const Duration(seconds: 1))); logger.log( eventType: FieldTestEventType.gpsStale, severity: EventSeverity.warning, message: 'GPS Stale event 1', rideId: 42, timestamp: now, ); logger.log( eventType: FieldTestEventType.gpsRecovered, severity: EventSeverity.info, message: 'GPS Recovered event 2', rideId: 42, timestamp: now.add(const Duration(seconds: 2)), ); await logger.flush(); final eventsInDb = await (db.select(db.fieldTestEvents) ..where((e) => e.rideId.equals(42)) ..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]) ).get(); expect(eventsInDb.length, greaterThanOrEqualTo(2)); expect(eventsInDb.first.eventType, 'RIDE_STARTED'); expect(eventsInDb[1].eventType, 'GPS_STALE'); expect(eventsInDb[2].eventType, 'GPS_RECOVERED'); }); test('Crash Recovery: Interrupted ride summary reconstruction', () async { final rideStart = DateTime(2026, 8, 28, 10, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: rideStart, )); // Insert samples without finishing the ride for (int i = 0; i < 30; i++) { await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert( rideId: rideId, timestamp: rideStart.add(Duration(seconds: i)), latitude: drift.Value(37.7749 + (i * 0.0002)), longitude: const drift.Value(-122.4194), speed: const drift.Value(45.0), gpsAccuracy: const drift.Value(3.0), )); } // Reconstruct summary final summary = await logger.generateAndSaveSummary(rideId, wasRecovered: true); expect(summary.wasRecoveredAfterCrash, isTrue); expect(summary.totalSamples, 30); expect(summary.validSamples, 30); expect(summary.rideQualityScore, greaterThanOrEqualTo(85)); expect(summary.rideQualityRating, 'EXCELLENT'); }); }); group('Phase 10: Forensic Export Generation', () { test('CSV export generates exact standard columns and formatted rows', () async { logger.startSession(99); final time = DateTime(2026, 8, 28, 15, 0, 0); logger.log( eventType: FieldTestEventType.gpsJumpRejected, severity: EventSeverity.error, message: 'Impossible velocity spike', rideId: 99, timestamp: time, speed: 150.0, gpsAccuracy: 5.0, ); await logger.flush(); final csv = await exportService.generateFieldTestEventsCsv(99); expect(csv, contains('timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata')); expect(csv, contains('GPS_JUMP_REJECTED')); expect(csv, contains('Impossible velocity spike')); }); test('JSON session export produces complete forensic payload', () async { final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: DateTime(2026, 8, 28, 16, 0, 0), distanceMiles: const drift.Value(15.2), topSpeedMph: const drift.Value(72.5), )); logger.startSession(rideId); await logger.flush(); await logger.generateAndSaveSummary(rideId); final jsonStr = await exportService.generateFieldTestSessionJson(rideId); expect(jsonStr, contains('"exportVersion": "1.0.0"')); expect(jsonStr, contains('"rideMetadata"')); expect(jsonStr, contains('"summary"')); expect(jsonStr, contains('"events"')); }); }); }