MotorSignal-App/test/field_test_logger_test.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);
});
});
}