216 lines
8.1 KiB
Dart
216 lines
8.1 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:drift/native.dart';
|
|
import 'package:motorcycle_dash/database/database.dart' hide FieldTestEvent, FieldTestSummary, 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/field_test/field_test_replay_service.dart';
|
|
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late AppDatabase db;
|
|
late FieldTestLogger logger;
|
|
late TelemetryValidator validator;
|
|
late FieldTestExportService exportService;
|
|
late FieldTestReplayService replayService;
|
|
|
|
setUp(() {
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
logger = FieldTestLogger(db);
|
|
validator = TelemetryValidator();
|
|
exportService = FieldTestExportService(db);
|
|
replayService = FieldTestReplayService(validator: validator);
|
|
});
|
|
|
|
tearDown(() async {
|
|
logger.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
group('Phase 11: Requirement 5 & 6 — Timing & Queue Observability Forensics', () {
|
|
test('Detects and logs timestamp regressions when sample time reverses', () async {
|
|
logger.startSession(101);
|
|
|
|
final t1 = DateTime(2026, 8, 28, 12, 0, 10);
|
|
final t2 = DateTime(2026, 8, 28, 12, 0, 5); // 5 seconds in the past!
|
|
|
|
final s1 = TelemetrySample(timestamp: t1, speed: 45.0, gpsAccuracy: 3.0);
|
|
final s2 = TelemetrySample(timestamp: t2, speed: 45.0, gpsAccuracy: 3.0);
|
|
|
|
logger.processTelemetrySample(s1, validator.validate(s1));
|
|
logger.processTelemetrySample(s2, validator.validate(s2));
|
|
|
|
await logger.flush();
|
|
|
|
final events = logger.timelineEvents;
|
|
expect(events.any((e) => e.eventType == FieldTestEventType.timestampError), isTrue);
|
|
expect(events.firstWhere((e) => e.eventType == FieldTestEventType.timestampError).severity, EventSeverity.error);
|
|
});
|
|
|
|
test('Queue bounds at capacity and drops INFO events first while retaining CRITICAL events', () async {
|
|
logger.startSession(102);
|
|
|
|
// Enqueue 999 INFO events
|
|
for (int i = 0; i < 999; i++) {
|
|
logger.log(
|
|
eventType: FieldTestEventType.sampleAccepted,
|
|
severity: EventSeverity.info,
|
|
message: 'Info sample #$i',
|
|
);
|
|
}
|
|
|
|
expect(logger.currentQueueDepth, 1000); // 1 start event + 999 info events = 1000 (at capacity)
|
|
|
|
// Add a critical event at full queue
|
|
logger.log(
|
|
eventType: FieldTestEventType.imuSaturation,
|
|
severity: EventSeverity.critical,
|
|
message: 'Critical IMU Saturation',
|
|
);
|
|
|
|
// Queue length should remain capped at 1000
|
|
expect(logger.currentQueueDepth, 1000);
|
|
expect(logger.droppedEventsCount, 1);
|
|
expect(logger.oldestDroppedItem, isNotNull);
|
|
|
|
await logger.flush();
|
|
expect(logger.totalFlushed, 1000);
|
|
expect(logger.currentQueueDepth, 0);
|
|
});
|
|
|
|
test('Tracks live peak queue depth, flush duration, and telemetry sample rate', () async {
|
|
logger.startSession(103);
|
|
final base = DateTime(2026, 8, 28, 14, 0, 0);
|
|
|
|
for (int i = 0; i < 20; i++) {
|
|
final sample = TelemetrySample(
|
|
timestamp: base.add(Duration(milliseconds: i * 50)),
|
|
speed: 35.0,
|
|
gpsAccuracy: 2.5,
|
|
);
|
|
logger.processTelemetrySample(sample, validator.validate(sample));
|
|
}
|
|
|
|
expect(logger.peakQueueDepth, greaterThan(0));
|
|
expect(logger.queueCapacity, 1000);
|
|
expect(logger.droppedEventsCount, 0);
|
|
expect(logger.failedFlushCount, 0);
|
|
});
|
|
});
|
|
|
|
group('Phase 11: Requirement 7 & 14 — Interrupted Crash Recovery & Summary Generation', () {
|
|
test('Reconstructs complete forensic summary for unclosed interrupted ride', () async {
|
|
final rideStart = DateTime(2026, 8, 28, 10, 0, 0);
|
|
|
|
// Insert unclosed ride (endTime is null)
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: rideStart,
|
|
));
|
|
|
|
// Simulate 50 realistic telemetry points
|
|
for (int i = 0; i < 50; i++) {
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: rideStart.add(Duration(seconds: i)),
|
|
latitude: drift.Value(37.7749 + (i * 0.0001)),
|
|
longitude: const drift.Value(-122.4194),
|
|
speed: const drift.Value(55.0),
|
|
gpsAccuracy: const drift.Value(3.2),
|
|
));
|
|
}
|
|
|
|
// Generate crash-recovery forensic summary
|
|
final summary = await logger.generateAndSaveSummary(rideId, wasRecovered: true);
|
|
|
|
expect(summary.wasRecoveredAfterCrash, isTrue);
|
|
expect(summary.wasInterrupted, isTrue);
|
|
expect(summary.totalSamples, 50);
|
|
expect(summary.validPercentage, 100.0);
|
|
expect(summary.rideQualityScore, greaterThanOrEqualTo(85));
|
|
expect(summary.rideQualityRating, 'EXCELLENT');
|
|
expect(summary.exportIntegrity, 'VERIFIED_OK');
|
|
});
|
|
});
|
|
|
|
group('Phase 11: Requirement 15 — Forensic Export Package Content & Structure', () {
|
|
test('Generates complete forensic session JSON and CSV with all required diagnostic headers', () async {
|
|
final rideStart = DateTime(2026, 8, 28, 11, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: rideStart,
|
|
endTime: drift.Value(rideStart.add(const Duration(minutes: 30))),
|
|
distanceMiles: const drift.Value(18.5),
|
|
topSpeedMph: const drift.Value(78.2),
|
|
));
|
|
|
|
logger.startSession(rideId);
|
|
logger.log(
|
|
eventType: FieldTestEventType.accelerationEvent,
|
|
severity: EventSeverity.info,
|
|
message: 'Hard launch 0.75g',
|
|
speed: 45.0,
|
|
longitudinalG: 0.75,
|
|
);
|
|
await logger.flush();
|
|
await logger.generateAndSaveSummary(rideId);
|
|
|
|
// Test CSV Export
|
|
final csv = await exportService.generateFieldTestEventsCsv(rideId);
|
|
expect(csv.startsWith('timestamp,ride_id,event_type,severity,message'), isTrue);
|
|
expect(csv.contains('ACCELERATION_EVENT'), isTrue);
|
|
|
|
// Test JSON Session Export
|
|
final jsonStr = await exportService.generateFieldTestSessionJson(rideId);
|
|
final Map<String, dynamic> parsed = jsonDecode(jsonStr);
|
|
expect(parsed['exportVersion'], '1.0.0');
|
|
expect(parsed['rideMetadata']['id'], rideId);
|
|
expect(parsed['summary']['totalSamples'], isNotNull);
|
|
expect(parsed['events'], isNotEmpty);
|
|
});
|
|
});
|
|
|
|
group('Phase 11: Requirement 16 — Deterministic Field-Test Replay Engine', () {
|
|
test('Offline replay of synthetic ride yields identical deterministic validity classifications', () async {
|
|
final base = DateTime(2026, 8, 28, 16, 0, 0);
|
|
final samples = <TelemetrySample>[];
|
|
|
|
// 100 normal cruising samples
|
|
for (int i = 0; i < 100; i++) {
|
|
samples.add(TelemetrySample(
|
|
timestamp: base.add(Duration(milliseconds: i * 50)),
|
|
latitude: 37.7749 + (i * 0.00005),
|
|
longitude: -122.4194 + (i * 0.00005),
|
|
speed: 45.0,
|
|
gpsAccuracy: 3.0,
|
|
leanAngle: 12.0,
|
|
longitudinalG: 0.05,
|
|
lateralG: 0.12,
|
|
));
|
|
}
|
|
|
|
// Add 1 invalid GPS teleport jump sample
|
|
samples.add(TelemetrySample(
|
|
timestamp: base.add(const Duration(milliseconds: 100 * 50)),
|
|
latitude: 38.5000, // Instant 80km jump!
|
|
longitude: -122.4194,
|
|
speed: 45.0,
|
|
gpsAccuracy: 3.0,
|
|
));
|
|
|
|
final result = await replayService.replaySamples(samples);
|
|
|
|
expect(result.totalSamples, 101);
|
|
expect(result.validSamples, 100);
|
|
expect(result.rejectedSamples, 1);
|
|
expect(result.validPercentage, closeTo(99.0, 0.1));
|
|
expect(result.rideQualityScore, greaterThanOrEqualTo(85));
|
|
expect(result.rideQualityRating, 'EXCELLENT');
|
|
expect(result.reconstructedEvents.any((e) => e.eventType == FieldTestEventType.sampleRejected), isTrue);
|
|
});
|
|
});
|
|
}
|