MotorSignal-App/test/phase10_forensic_logger_tes...

255 lines
9.4 KiB
Dart

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 {
logger.startSession(42);
final now = DateTime(2026, 8, 28, 14, 0, 0);
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"'));
});
});
}