330 lines
11 KiB
Dart
330 lines
11 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.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';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_models.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late AppDatabase db;
|
|
late ProviderContainer container;
|
|
late FieldTestLogger logger;
|
|
late FieldTestExportService exportService;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
databaseProvider.overrideWithValue(db),
|
|
],
|
|
);
|
|
logger = container.read(fieldTestLoggerProvider);
|
|
exportService = container.read(fieldTestExportServiceProvider);
|
|
});
|
|
|
|
tearDown(() async {
|
|
container.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
group('Field Test Logger - Session Management & Lifecycle', () {
|
|
test('startSession creates a new session and records environment info', () async {
|
|
final bike = GarageMotorcycle(
|
|
id: 1,
|
|
year: 2024,
|
|
makeId: 'ducati',
|
|
makeName: 'Ducati',
|
|
modelId: 'panigale_v4',
|
|
modelName: 'Panigale V4',
|
|
variantId: 's',
|
|
variantName: 'S',
|
|
nickname: 'Red Beast',
|
|
isDefault: true,
|
|
dateAdded: DateTime.now(),
|
|
lastUpdated: DateTime.now(),
|
|
);
|
|
|
|
final sessionId = await logger.startSession(
|
|
rideId: 101,
|
|
bike: bike,
|
|
rollOffset: 1.5,
|
|
pitchOffset: -0.8,
|
|
);
|
|
|
|
expect(sessionId, isNotNull);
|
|
expect(logger.isSessionActive, isTrue);
|
|
expect(logger.activeSessionId, equals(sessionId));
|
|
|
|
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
|
expect(session.rideId, equals(101));
|
|
expect(session.motorcycleMake, equals('Ducati'));
|
|
expect(session.motorcycleModel, equals('Panigale V4'));
|
|
expect(session.calibrationRollOffset, equals(1.5));
|
|
expect(session.calibrationPitchOffset, equals(-0.8));
|
|
expect(session.completed, isFalse);
|
|
});
|
|
|
|
test('stopSession finalizes session, flushes data, and calculates health summary', () async {
|
|
final sessionId = await logger.startSession(rideId: 102);
|
|
expect(sessionId, isNotNull);
|
|
|
|
// Log some events
|
|
logger.logEvent(
|
|
eventType: FieldTestEventType.gpsFix,
|
|
severity: FieldTestSeverity.debug,
|
|
message: 'GPS fix acquired',
|
|
);
|
|
|
|
logger.logGpsEvent(
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
accuracy: 5.0,
|
|
gpsHealth: 'Healthy',
|
|
accepted: true,
|
|
);
|
|
|
|
await logger.stopSession();
|
|
|
|
expect(logger.isSessionActive, isFalse);
|
|
expect(logger.activeSessionId, isNull);
|
|
|
|
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
|
expect(session.completed, isTrue);
|
|
expect(session.endedAt, isNotNull);
|
|
expect(session.healthSummaryJson, isNotNull);
|
|
});
|
|
|
|
test('Immediate flush occurs on critical severity events', () async {
|
|
final sessionId = await logger.startSession(rideId: 103);
|
|
expect(sessionId, isNotNull);
|
|
|
|
logger.logEvent(
|
|
eventType: FieldTestEventType.loggerError,
|
|
severity: FieldTestSeverity.critical,
|
|
message: 'Critical subsystem failure occurred',
|
|
);
|
|
|
|
// Critical event should immediately flush to DB without waiting 5s timer
|
|
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
|
|
expect(events.any((e) => e.severity == 'critical'), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Field Test Logger - GPS Diagnostics & Outlier Rejection', () {
|
|
test('Records accepted and rejected GPS points with explicit rejection reasons', () async {
|
|
final sessionId = await logger.startSession(rideId: 104);
|
|
expect(sessionId, isNotNull);
|
|
|
|
// Accepted fix
|
|
logger.logGpsEvent(
|
|
latitude: 34.0522,
|
|
longitude: -118.2437,
|
|
rawSpeed: 25.0,
|
|
accuracy: 4.2,
|
|
gpsHealth: 'Healthy',
|
|
accepted: true,
|
|
);
|
|
|
|
// Rejected fix (impossible speed)
|
|
logger.logGpsEvent(
|
|
latitude: 34.0530,
|
|
longitude: -118.2440,
|
|
rawSpeed: 120.0, // 268 MPH
|
|
accuracy: 45.0,
|
|
gpsHealth: 'Poor',
|
|
accepted: false,
|
|
rejectionReason: 'Impossible speed (268.4 MPH > 223 MPH)',
|
|
);
|
|
|
|
await logger.flush();
|
|
|
|
final gpsRecords = await (db.select(db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(sessionId!))).get();
|
|
expect(gpsRecords.length, equals(2));
|
|
expect(gpsRecords.where((g) => g.accepted).length, equals(1));
|
|
expect(gpsRecords.where((g) => !g.accepted).length, equals(1));
|
|
expect(gpsRecords.firstWhere((g) => !g.accepted).rejectionReason, contains('Impossible speed'));
|
|
});
|
|
|
|
test('Tracks GPS outage duration and recovery time accurately', () async {
|
|
await logger.startSession(rideId: 105);
|
|
|
|
logger.logGpsSignalLoss();
|
|
// Simulate brief delay
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
logger.logGpsSignalRecovery();
|
|
logger.logGpsBaselineReset();
|
|
|
|
await logger.flush();
|
|
|
|
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsLost), isTrue);
|
|
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsRecovered), isTrue);
|
|
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsBaselineReset), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Field Test Logger - IMU & Sensor Fusion Diagnostics', () {
|
|
test('Detects and logs vibration spikes exceeding delta threshold', () async {
|
|
final sessionId = await logger.startSession(rideId: 106);
|
|
expect(sessionId, isNotNull);
|
|
|
|
// Normal IMU sample (delta = 0.05G)
|
|
logger.logImuDiagnostic(
|
|
accelX: 0.1,
|
|
accelY: 0.5,
|
|
accelZ: 9.8,
|
|
longitudinalG: 0.52,
|
|
filteredLongitudinalG: 0.50,
|
|
);
|
|
|
|
// Vibration spike (delta = 1.34G > 0.8G threshold)
|
|
logger.logImuDiagnostic(
|
|
accelX: 0.2,
|
|
accelY: 2.14,
|
|
accelZ: 9.8,
|
|
longitudinalG: 2.14,
|
|
filteredLongitudinalG: 0.80,
|
|
);
|
|
|
|
await logger.flush();
|
|
|
|
final imuRecords = await (db.select(db.fieldTestImuRecords)..where((i) => i.sessionId.equals(sessionId!))).get();
|
|
expect(imuRecords.length, equals(2));
|
|
|
|
final spikeEvents = logger.liveLogs.where((l) => l.eventType == FieldTestEventType.imuSpikeFiltered);
|
|
expect(spikeEvents.length, equals(1));
|
|
expect(spikeEvents.first.message, contains('Vibration Spike Filtered'));
|
|
});
|
|
});
|
|
|
|
group('Field Test Logger - Road Alerts & TTS', () {
|
|
test('Logs alert reception and TTS speech results', () async {
|
|
await logger.startSession(rideId: 107);
|
|
|
|
logger.logRoadAlertEvent(
|
|
type: FieldTestEventType.roadAlertReceived,
|
|
providerId: 'community_traffic',
|
|
category: 'Police',
|
|
title: 'Speed Trap Reported',
|
|
distanceMeters: 250,
|
|
);
|
|
|
|
logger.logTts(success: true, phrase: 'Police report ahead.');
|
|
logger.logTts(success: false, phrase: 'Hazard ahead.', error: 'Audio focus lost');
|
|
|
|
expect(logger.liveLogs.any((l) => l.message.contains('Police')), isTrue);
|
|
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsSuccess), isTrue);
|
|
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsFailure), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Field Test Logger - Safe Retention & Active Protection', () {
|
|
test('Active session is protected from deletion', () async {
|
|
final sessionId = await logger.startSession(rideId: 108);
|
|
expect(sessionId, isNotNull);
|
|
|
|
final deleted = await logger.deleteSession(sessionId!);
|
|
expect(deleted, isFalse); // Must reject active session deletion
|
|
|
|
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
|
expect(session, isNotNull);
|
|
});
|
|
|
|
test('Inactive session can be deleted with cascade cleanup', () async {
|
|
final sessionId = await logger.startSession(rideId: 109);
|
|
expect(sessionId, isNotNull);
|
|
|
|
logger.logEvent(
|
|
eventType: FieldTestEventType.speedChanged,
|
|
severity: FieldTestSeverity.info,
|
|
message: 'Speed 45 MPH',
|
|
);
|
|
await logger.stopSession();
|
|
|
|
final deleted = await logger.deleteSession(sessionId!);
|
|
expect(deleted, isTrue);
|
|
|
|
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
|
|
expect(session, isNull);
|
|
|
|
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId))).get();
|
|
expect(events.isEmpty, isTrue);
|
|
});
|
|
});
|
|
|
|
group('Field Test Logger - Export Service', () {
|
|
test('Generates complete ZIP archive with README, JSON, CSVs, and diagnostics', () async {
|
|
// 1. Create a simulated ride in DB
|
|
final rideId = await db.into(db.rides).insert(
|
|
RidesCompanion.insert(
|
|
startTime: DateTime.now().subtract(const Duration(minutes: 20)),
|
|
endTime: drift.Value(DateTime.now()),
|
|
distanceMiles: const drift.Value(12.4),
|
|
topSpeedMph: const drift.Value(78.5),
|
|
averageSpeed: const drift.Value(45.2),
|
|
maxLeanLeft: const drift.Value(34.2),
|
|
maxLeanRight: const drift.Value(31.8),
|
|
maxAccelG: const drift.Value(0.85),
|
|
maxBrakingG: const drift.Value(-0.92),
|
|
movingTime: const drift.Value(960),
|
|
stoppedTime: const drift.Value(240),
|
|
motorcycleMake: const drift.Value('Yamaha'),
|
|
motorcycleModel: const drift.Value('MT-09'),
|
|
),
|
|
);
|
|
|
|
// 2. Create telemetry samples for the ride
|
|
await db.into(db.telemetrySamples).insert(
|
|
TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: DateTime.now(),
|
|
latitude: const drift.Value(37.7749),
|
|
longitude: const drift.Value(-122.4194),
|
|
speed: const drift.Value(45.0),
|
|
heading: const drift.Value(180.0),
|
|
leanAngle: const drift.Value(-15.2),
|
|
longitudinalG: const drift.Value(0.24),
|
|
lateralG: const drift.Value(0.12),
|
|
),
|
|
);
|
|
|
|
// 3. Start & log field test session
|
|
final sessionId = await logger.startSession(rideId: rideId);
|
|
expect(sessionId, isNotNull);
|
|
|
|
logger.logGpsEvent(
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
rawSpeed: 20.1,
|
|
filteredSpeed: 20.1,
|
|
gpsHealth: 'Healthy',
|
|
accepted: true,
|
|
);
|
|
|
|
logger.logImuDiagnostic(
|
|
accelX: 0.1,
|
|
accelY: 0.2,
|
|
accelZ: 9.8,
|
|
longitudinalG: 0.2,
|
|
lateralG: 0.1,
|
|
);
|
|
|
|
await logger.stopSession();
|
|
|
|
// 4. Test README / Report generation directly
|
|
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
|
|
expect(session.completed, isTrue);
|
|
|
|
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
|
|
expect(events.isNotEmpty, isTrue);
|
|
expect(exportService, isNotNull);
|
|
});
|
|
});
|
|
}
|