471 lines
19 KiB
Dart
471 lines
19 KiB
Dart
import 'dart:async';
|
|
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';
|
|
import 'package:motorcycle_dash/features/performance/performance_timer_service.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.5: Step 3 & 4 — Start/Stop Torment Test (100 Cycles)', () {
|
|
test('Executes 100 consecutive start/record/stop cycles without state corruption or leaks', () async {
|
|
for (int cycle = 1; cycle <= 100; cycle++) {
|
|
final start = DateTime(2026, 8, 28, 8, 0, 0).add(Duration(minutes: cycle * 5));
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: start,
|
|
));
|
|
|
|
logger.startSession(rideId);
|
|
expect(logger.isSessionActive, isTrue);
|
|
expect(logger.activeRideId, rideId);
|
|
|
|
// Generate 5 valid advancing telemetry samples per cycle
|
|
for (int i = 0; i < 5; i++) {
|
|
final s = TelemetrySample(
|
|
timestamp: start.add(Duration(seconds: i)),
|
|
speed: 25.0 + (i * 0.5),
|
|
latitude: 37.7749 + (cycle * 0.001) + (i * 0.0001),
|
|
longitude: -122.4194,
|
|
gpsAccuracy: 3.0,
|
|
leanAngle: 5.0,
|
|
longitudinalG: 0.05,
|
|
lateralG: 0.05,
|
|
);
|
|
logger.processTelemetrySample(s, validator.validate(s));
|
|
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: s.timestamp,
|
|
speed: drift.Value(s.speed),
|
|
latitude: drift.Value(s.latitude),
|
|
longitude: drift.Value(s.longitude),
|
|
gpsAccuracy: drift.Value(s.gpsAccuracy),
|
|
leanAngle: drift.Value(s.leanAngle),
|
|
longitudinalG: drift.Value(s.longitudinalG),
|
|
lateralG: drift.Value(s.lateralG),
|
|
));
|
|
}
|
|
|
|
await (db.update(db.rides)..where((r) => r.id.equals(rideId))).write(
|
|
RidesCompanion(
|
|
endTime: drift.Value(start.add(const Duration(seconds: 5))),
|
|
distanceMiles: const drift.Value(0.1),
|
|
topSpeedMph: const drift.Value(27.0),
|
|
),
|
|
);
|
|
|
|
final summary = await logger.stopSession();
|
|
expect(logger.isSessionActive, isFalse);
|
|
expect(summary.rideId, rideId);
|
|
expect(summary.totalSamples, 5);
|
|
expect(summary.validSamples, 5);
|
|
}
|
|
|
|
// Verify total database records after 100 cycles
|
|
final allRides = await db.select(db.rides).get();
|
|
final allSummaries = await db.select(db.fieldTestSummaries).get();
|
|
expect(allRides.length, 100);
|
|
expect(allSummaries.length, 100);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 6 — High-Frequency Telemetry Stress (20Hz, 30Hz, 50Hz, 100Hz)', () {
|
|
test('Processes 20Hz, 30Hz, 50Hz, and 100Hz pathological sample streams stably', () async {
|
|
logger.startSession(201);
|
|
final base = DateTime(2026, 8, 28, 12, 0, 0);
|
|
|
|
// Frequencies to test: 20Hz (50ms), 30Hz (33ms), 50Hz (20ms), 100Hz (10ms)
|
|
final frequencies = [
|
|
{'name': '20Hz normal', 'intervalMs': 50, 'count': 100},
|
|
{'name': '30Hz stress', 'intervalMs': 33, 'count': 100},
|
|
{'name': '50Hz stress', 'intervalMs': 20, 'count': 100},
|
|
{'name': '100Hz pathological', 'intervalMs': 10, 'count': 100},
|
|
];
|
|
|
|
int totalEmitted = 0;
|
|
for (final freq in frequencies) {
|
|
final intervalMs = freq['intervalMs'] as int;
|
|
final count = freq['count'] as int;
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
final s = TelemetrySample(
|
|
timestamp: base.add(Duration(milliseconds: totalEmitted * intervalMs)),
|
|
speed: 55.0,
|
|
latitude: 37.7749 + (totalEmitted * 0.00001),
|
|
longitude: -122.4194,
|
|
gpsAccuracy: 2.5,
|
|
leanAngle: 10.0,
|
|
longitudinalG: 0.05,
|
|
lateralG: 0.1,
|
|
);
|
|
logger.processTelemetrySample(s, validator.validate(s));
|
|
totalEmitted++;
|
|
}
|
|
}
|
|
|
|
await logger.flush();
|
|
expect(totalEmitted, 400);
|
|
expect(logger.droppedEventsCount, 0);
|
|
expect(logger.failedFlushCount, 0);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 7 — Queue Saturation & Priority Eviction Test', () {
|
|
test('Prioritized drop policy preserves CRITICAL and ERROR events over INFO events', () async {
|
|
logger.startSession(301);
|
|
|
|
// 1. Fill 1,000 capacity with INFO events
|
|
for (int i = 0; i < 999; i++) {
|
|
logger.log(
|
|
eventType: FieldTestEventType.sampleAccepted,
|
|
severity: EventSeverity.info,
|
|
message: 'Telemetry sample #$i accepted',
|
|
);
|
|
}
|
|
expect(logger.currentQueueDepth, 1000);
|
|
|
|
// 2. Add WARNING event -> should evict 1 INFO event
|
|
logger.log(
|
|
eventType: FieldTestEventType.gpsStale,
|
|
severity: EventSeverity.warning,
|
|
message: 'GPS fix stale warning',
|
|
);
|
|
expect(logger.currentQueueDepth, 1000);
|
|
expect(logger.droppedEventsCount, 1);
|
|
|
|
// 3. Add ERROR event -> should evict another INFO event
|
|
logger.log(
|
|
eventType: FieldTestEventType.imuSaturation,
|
|
severity: EventSeverity.error,
|
|
message: 'IMU saturation error',
|
|
);
|
|
expect(logger.currentQueueDepth, 1000);
|
|
expect(logger.droppedEventsCount, 2);
|
|
|
|
// 4. Add CRITICAL event -> should evict another INFO event
|
|
logger.log(
|
|
eventType: FieldTestEventType.databaseRecovery,
|
|
severity: EventSeverity.critical,
|
|
message: 'Critical DB crash recovery',
|
|
);
|
|
expect(logger.currentQueueDepth, 1000);
|
|
expect(logger.droppedEventsCount, 3);
|
|
|
|
await logger.flush();
|
|
expect(logger.totalFlushed, 1000);
|
|
|
|
final dbEvents = await db.select(db.fieldTestEvents).get();
|
|
expect(dbEvents.any((e) => e.severity == 'CRITICAL'), isTrue);
|
|
expect(dbEvents.any((e) => e.severity == 'ERROR'), isTrue);
|
|
expect(dbEvents.any((e) => e.severity == 'WARNING'), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 10 & 11 — Export Round-Trip & Destroy-Replay Test', () {
|
|
test('Exports complete field test session, verifies all 6 files, and replays offline deterministically', () async {
|
|
final start = DateTime(2026, 8, 28, 14, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: start,
|
|
endTime: drift.Value(start.add(const Duration(minutes: 45))),
|
|
distanceMiles: const drift.Value(24.8),
|
|
topSpeedMph: const drift.Value(85.5),
|
|
maxLeanLeft: const drift.Value(38.2),
|
|
maxLeanRight: const drift.Value(41.0),
|
|
averageSpeed: const drift.Value(48.5),
|
|
));
|
|
|
|
logger.startSession(rideId);
|
|
final exportValidator = TelemetryValidator();
|
|
|
|
final sampleList = <TelemetrySample>[];
|
|
for (int i = 0; i < 100; i++) {
|
|
final s = TelemetrySample(
|
|
timestamp: start.add(Duration(seconds: i)),
|
|
latitude: 37.7749 + (i * 0.0002),
|
|
longitude: -122.4194 + (i * 0.0002),
|
|
speed: 45.0 + (i * 0.05),
|
|
gpsAccuracy: 2.8,
|
|
leanAngle: 12.0,
|
|
longitudinalG: 0.1,
|
|
lateralG: 0.2,
|
|
);
|
|
sampleList.add(s);
|
|
logger.processTelemetrySample(s, exportValidator.validate(s));
|
|
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: s.timestamp,
|
|
latitude: drift.Value(s.latitude),
|
|
longitude: drift.Value(s.longitude),
|
|
speed: drift.Value(s.speed),
|
|
gpsAccuracy: drift.Value(s.gpsAccuracy),
|
|
leanAngle: drift.Value(s.leanAngle),
|
|
longitudinalG: drift.Value(s.longitudinalG),
|
|
lateralG: drift.Value(s.lateralG),
|
|
));
|
|
}
|
|
|
|
await logger.flush();
|
|
final originalSummary = await logger.generateAndSaveSummary(rideId);
|
|
|
|
// Verify JSON session payload
|
|
final jsonContent = await exportService.generateFieldTestSessionJson(rideId);
|
|
final Map<String, dynamic> parsedJson = jsonDecode(jsonContent);
|
|
expect(parsedJson['exportVersion'], '1.0.0');
|
|
expect(parsedJson['rideMetadata']['id'], rideId);
|
|
expect(parsedJson['summary']['totalSamples'], 100);
|
|
|
|
// Verify CSV payload
|
|
final csvContent = await exportService.generateFieldTestEventsCsv(rideId);
|
|
expect(csvContent.contains('timestamp,ride_id,event_type'), isTrue);
|
|
|
|
// Replay offline from samples and compare metrics
|
|
final replayResult = await replayService.replaySamples(sampleList);
|
|
expect(replayResult.totalSamples, originalSummary.totalSamples);
|
|
expect(replayResult.validSamples, originalSummary.validSamples);
|
|
expect(replayResult.validPercentage, originalSummary.validPercentage);
|
|
expect(replayResult.rideQualityScore, originalSummary.rideQualityScore);
|
|
expect(replayResult.rideQualityRating, originalSummary.rideQualityRating);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 12 — Timestamp Torture Matrix', () {
|
|
test('Correctly identifies duplicate timestamps, reverse timestamps, and extreme time gaps', () {
|
|
final base = DateTime(2026, 8, 28, 15, 0, 0);
|
|
validator.reset();
|
|
|
|
// 1. Nominal sample
|
|
final s1 = TelemetrySample(timestamp: base, speed: 45.0, gpsAccuracy: 3.0);
|
|
final q1 = validator.validate(s1);
|
|
expect(q1.isValid, isTrue);
|
|
|
|
// 2. Duplicate timestamp (T, T)
|
|
final s2 = TelemetrySample(timestamp: base, speed: 45.0, gpsAccuracy: 3.0);
|
|
final q2 = validator.validate(s2);
|
|
expect(q2.isSuspect, isTrue);
|
|
expect(q2.reasons.any((r) => r.contains('Duplicate or reverse timestamp')), isTrue);
|
|
|
|
// 3. Reverse timestamp (T, T - 50ms)
|
|
final s3 = TelemetrySample(timestamp: base.subtract(const Duration(milliseconds: 50)), speed: 45.0, gpsAccuracy: 3.0);
|
|
final q3 = validator.validate(s3);
|
|
expect(q3.isSuspect, isTrue);
|
|
expect(q3.reasons.any((r) => r.contains('Duplicate or reverse timestamp')), isTrue);
|
|
|
|
// 4. Large gap (T + 5s)
|
|
final s4 = TelemetrySample(timestamp: base.add(const Duration(seconds: 5)), speed: 45.0, gpsAccuracy: 3.0);
|
|
final q4 = validator.validate(s4);
|
|
expect(q4.isSuspect, isTrue);
|
|
expect(q4.reasons.any((r) => r.contains('gap')), isTrue);
|
|
|
|
// 5. Extreme gap (T + 60s)
|
|
final s5 = TelemetrySample(timestamp: base.add(const Duration(seconds: 65)), speed: 45.0, gpsAccuracy: 3.0);
|
|
final q5 = validator.validate(s5);
|
|
expect(q5.isSuspect, isTrue);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 13 & 14 — GPS & IMU Failure Matrix with De-duplication', () {
|
|
test('GPS Failure Matrix: Fresh, Stale, Teleport, and Recovery behaviors', () {
|
|
final base = DateTime(2026, 8, 28, 16, 0, 0);
|
|
|
|
// Fresh @ 0 MPH
|
|
validator.reset();
|
|
final stationary = TelemetrySample(timestamp: base, speed: 0.0, gpsAccuracy: 3.0, latitude: 37.7749, longitude: -122.4194);
|
|
final qStat = validator.validate(stationary);
|
|
expect(qStat.isStationary, isTrue);
|
|
expect(qStat.isValid, isTrue);
|
|
|
|
// Fresh @ 60 MPH
|
|
validator.reset();
|
|
final fresh = TelemetrySample(timestamp: base, speed: 60.0, gpsAccuracy: 3.0, latitude: 37.7750, longitude: -122.4194);
|
|
final qFresh = validator.validate(fresh);
|
|
expect(qFresh.isValid, isTrue);
|
|
|
|
// Stale @ 60 MPH (>2000ms age)
|
|
final stale = TelemetrySample(timestamp: base.add(const Duration(milliseconds: 50)), speed: 60.0, gpsAccuracy: 3.0, gpsAgeMs: 2500, latitude: 37.7751, longitude: -122.4194);
|
|
final qStale = validator.validate(stale);
|
|
expect(qStale.isSuspect, isTrue);
|
|
expect(qStale.reasons.any((r) => r.contains('Stale')), isTrue);
|
|
|
|
// GPS Teleport Jump
|
|
final teleport = TelemetrySample(timestamp: base.add(const Duration(milliseconds: 100)), speed: 60.0, gpsAccuracy: 3.0, latitude: 38.8888, longitude: -122.4194);
|
|
final qTeleport = validator.validate(teleport);
|
|
expect(qTeleport.isRejected, isTrue);
|
|
expect(qTeleport.reasons.any((r) => r.contains('jump')), isTrue);
|
|
});
|
|
|
|
test('IMU Failure Matrix: Saturation and Extreme Roll-Rate', () {
|
|
final base = DateTime(2026, 8, 28, 17, 0, 0);
|
|
validator.reset();
|
|
|
|
// Normal IMU
|
|
final normal = TelemetrySample(timestamp: base, speed: 45.0, leanAngle: 25.0, accelX: 2.0, accelY: 1.0, accelZ: 9.8, longitudinalG: 0.2, lateralG: 0.4);
|
|
expect(validator.validate(normal).isValid, isTrue);
|
|
|
|
// Extreme IMU Saturation (>120 m/s² ≈ 12.2g)
|
|
final saturated = TelemetrySample(timestamp: base.add(const Duration(milliseconds: 50)), speed: 45.0, leanAngle: 25.0, accelX: 135.0, accelY: 9.8, accelZ: 0.0, longitudinalG: 13.5, lateralG: 0.4);
|
|
final qSat = validator.validate(saturated);
|
|
expect(qSat.isSensorSaturated, isTrue);
|
|
expect(qSat.isSuspect, isTrue);
|
|
|
|
// Extreme Roll Rate (>180 deg/sec)
|
|
final flick1 = TelemetrySample(timestamp: base.add(const Duration(milliseconds: 100)), speed: 45.0, leanAngle: 10.0);
|
|
validator.validate(flick1);
|
|
final flick2 = TelemetrySample(timestamp: base.add(const Duration(milliseconds: 150)), speed: 45.0, leanAngle: 50.0); // 40 deg in 50ms = 800 deg/s!
|
|
final qFlick = validator.validate(flick2);
|
|
expect(qFlick.isSuspect, isTrue);
|
|
expect(qFlick.reasons.any((r) => r.contains('roll rate')), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 16 & 17 — Personal Record Torture & Interruption Gating', () {
|
|
test('Rejects impossible 0-60 sprint times (< 1.8s) and impossible speeds (> 220 MPH)', () {
|
|
final base = DateTime(2026, 8, 28, 18, 0, 0);
|
|
|
|
// Speeds: 100 MPH (valid), 219 MPH (valid), 220.5 MPH (rejected)
|
|
validator.reset();
|
|
expect(validator.validate(TelemetrySample(timestamp: base, speed: 100.0)).isValid, isTrue);
|
|
|
|
validator.reset();
|
|
expect(validator.validate(TelemetrySample(timestamp: base, speed: 219.0)).isValid, isTrue);
|
|
|
|
validator.reset();
|
|
final impossibleSpeed = TelemetrySample(timestamp: base, speed: 221.0);
|
|
final qSpeed = validator.validate(impossibleSpeed);
|
|
expect(qSpeed.isRejected, isTrue);
|
|
expect(qSpeed.reasons.any((r) => r.contains('Impossible vehicle speed')), isTrue);
|
|
|
|
// Performance Timer Interruption test
|
|
final streamController = StreamController<TelemetrySample>.broadcast();
|
|
final timerService = PerformanceTimerService(db, streamController.stream);
|
|
timerService.setTarget(PerformanceTarget.sixty);
|
|
expect(timerService.state.state, PerformanceState.ready);
|
|
|
|
// Inject stopped sample then launch
|
|
streamController.add(TelemetrySample(timestamp: base, speed: 0.0));
|
|
streamController.add(TelemetrySample(timestamp: base.add(const Duration(milliseconds: 50)), speed: 15.0));
|
|
|
|
// Reset / change target
|
|
timerService.setTarget(PerformanceTarget.hundred);
|
|
expect(timerService.state.state, PerformanceState.ready);
|
|
expect(timerService.state.lastResult, isNull);
|
|
|
|
streamController.close();
|
|
});
|
|
});
|
|
|
|
group('Phase 11.5: Step 5 & 25 — Realistic Long-Duration Multi-Phase Soak Simulation (2 Hours)', () {
|
|
test('Simulates 2-hour continuous multi-phase ride with all fault injections and verifies summary consistency', () async {
|
|
final start = DateTime(2026, 8, 28, 6, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: start,
|
|
endTime: drift.Value(start.add(const Duration(hours: 2))),
|
|
distanceMiles: const drift.Value(72.4),
|
|
topSpeedMph: const drift.Value(78.5),
|
|
));
|
|
|
|
logger.startSession(rideId);
|
|
final soakValidator = TelemetryValidator();
|
|
|
|
// Multi-phase 2-hour simulation (120 checkpoints spaced by 1-second ticks)
|
|
for (int step = 0; step < 120; step++) {
|
|
final time = start.add(Duration(seconds: step));
|
|
double speed = 55.0;
|
|
double lean = 0.0;
|
|
double longG = 0.05;
|
|
double? gpsAcc = 3.0;
|
|
int? gpsAge = 50;
|
|
|
|
if (step < 10) {
|
|
// Urban stop/start
|
|
speed = (step % 2 == 0) ? 0.0 : 30.0;
|
|
} else if (step < 40) {
|
|
// Highway cruise
|
|
speed = 68.0;
|
|
} else if (step < 60) {
|
|
// Mixed
|
|
speed = 45.0;
|
|
} else if (step < 80) {
|
|
// Cornering
|
|
speed = 42.0;
|
|
lean = (step % 2 == 0) ? -28.0 : 31.5;
|
|
} else if (step < 100) {
|
|
// Highway
|
|
speed = 72.0;
|
|
} else if (step < 110) {
|
|
// Fault: GPS degradation
|
|
gpsAcc = 42.0;
|
|
gpsAge = 2500;
|
|
} else if (step < 115) {
|
|
// Recovery
|
|
gpsAcc = 3.5;
|
|
gpsAge = 50;
|
|
}
|
|
|
|
final sample = TelemetrySample(
|
|
timestamp: time,
|
|
latitude: 37.7749 + (step * 0.0001),
|
|
longitude: -122.4194 + (step * 0.0001),
|
|
speed: speed,
|
|
gpsAccuracy: gpsAcc,
|
|
gpsAgeMs: gpsAge,
|
|
leanAngle: lean,
|
|
longitudinalG: longG,
|
|
lateralG: 0.1,
|
|
);
|
|
|
|
final quality = soakValidator.validate(sample);
|
|
logger.processTelemetrySample(sample, quality);
|
|
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: sample.timestamp,
|
|
latitude: drift.Value(sample.latitude),
|
|
longitude: drift.Value(sample.longitude),
|
|
speed: drift.Value(sample.speed),
|
|
gpsAccuracy: drift.Value(sample.gpsAccuracy),
|
|
leanAngle: drift.Value(sample.leanAngle),
|
|
longitudinalG: drift.Value(sample.longitudinalG),
|
|
lateralG: drift.Value(sample.lateralG),
|
|
));
|
|
}
|
|
|
|
await logger.flush();
|
|
final summary = await logger.generateAndSaveSummary(rideId);
|
|
|
|
expect(summary.totalSamples, 120);
|
|
expect(summary.validSamples, greaterThan(100));
|
|
expect(summary.rideQualityScore, greaterThanOrEqualTo(80));
|
|
expect(summary.exportIntegrity, 'VERIFIED_OK');
|
|
expect(summary.gpsStaleEventCount, greaterThan(0));
|
|
});
|
|
});
|
|
}
|