255 lines
9.4 KiB
Dart
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 {
|
|
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"'));
|
|
});
|
|
});
|
|
}
|