MotorSignal-App/test/forensic_lifecycle_audit_te...

324 lines
11 KiB
Dart

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:drift/native.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/models/recording_health_status.dart';
import 'package:motorcycle_dash/services/telemetry_service.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
import 'package:motorcycle_dash/services/compass_service.dart';
import 'package:motorcycle_dash/services/audio_alert_service.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
import 'package:motorcycle_dash/features/rides/ride_state_machine.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
class MockAudioAlertService extends Fake implements AudioAlertService {
final List<String> spokenMessages = [];
@override
Future<void> speak(String text, {bool force = false}) async {
spokenMessages.add(text);
}
}
class FakeTelemetryService extends TelemetryService {
final StreamController<TelemetrySample> _controller = StreamController<TelemetrySample>.broadcast();
FakeTelemetryService(
super.loc,
super.fusion,
super.compass,
super.logger,
super.validator,
);
@override
Stream<TelemetrySample> get telemetryStream => _controller.stream;
void emitSample(TelemetrySample sample) {
_controller.add(sample);
}
@override
void dispose() {
_controller.close();
}
}
class _NullLocationService extends Fake implements LocationService {
@override
void Function(Position position, bool accepted, String? rejectionReason)? onGpsDiagnostic;
@override
Stream<Position> getFilteredPositionStream() => const Stream.empty();
}
class _NullSensorFusionService extends Fake implements SensorFusionService {
@override
Stream<double> get leanAngleStream => const Stream.empty();
@override
Stream<double> get pitchAngleStream => const Stream.empty();
@override
Stream<GForceData> get gForceStream => const Stream.empty();
@override
double get latestRollRateDegPerSec => 0.0;
@override
double get latestRawAccelX => 0.0;
@override
double get latestRawAccelY => 0.0;
@override
double get latestRawAccelZ => 0.0;
@override
double get latestRawGyroX => 0.0;
@override
double get latestRawGyroY => 0.0;
@override
double get latestRawGyroZ => 0.0;
}
class _NullCompassService extends Fake implements CompassService {
@override
Stream<double> get headingStream => const Stream.empty();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FakeTelemetryService fakeTelemetry;
late MockAudioAlertService mockAudio;
late FieldTestLogger logger;
late TelemetryValidator validator;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
validator = TelemetryValidator();
fakeTelemetry = FakeTelemetryService(
_NullLocationService(),
_NullSensorFusionService(),
_NullCompassService(),
logger,
validator,
);
mockAudio = MockAudioAlertService();
});
tearDown(() async {
fakeTelemetry.dispose();
await db.close();
});
test('Physical Failure Regression: Starting ride creates DB session, warm-up health, and logs lifecycle', () async {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
// Initial state: Idle
var session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.idle));
expect(session.dbId, isNull);
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
// Verify state transitioned to RIDING and received a valid DB ID!
session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.riding));
expect(session.dbId, isNotNull);
// RID-3C-1: a freshly created ride has NO evidence yet, so it must report
// warm-up health instead of falsely claiming an active recording. Health
// promotes automatically once real GPS / IMU / telemetry / DB-commit evidence
// arrives; see test/rid3c1_truthful_recording_health_test.dart (T1, T2).
expect(session.healthStatus, equals(RecordingHealthStatus.recordingArmed));
expect(session.health.canReportRecording, isFalse);
expect(session.health.isValidSessionId, isTrue);
// Emit 10 telemetry samples
for (int i = 0; i < 10; i++) {
fakeTelemetry.emitSample(
TelemetrySample(
timestamp: DateTime.now(),
latitude: 34.2570 + (i * 0.001),
longitude: -85.1647 + (i * 0.001),
speed: 45.0 + i,
heading: 90.0,
leanAngle: 12.0,
longitudinalG: 0.2,
lateralG: 0.1,
gpsAccuracy: 4.0,
),
);
}
await Future.delayed(const Duration(milliseconds: 100));
// Stop session
await notifier.finishRide();
await logger.flush();
// Verify ride in SQLite database is non-empty and properly finalized
final rides = await db.select(db.rides).get();
expect(rides.length, equals(1));
expect(rides.first.endTime, isNotNull);
expect(rides.first.topSpeedMph, greaterThanOrEqualTo(45.0));
// Verify field test events captured lifecycle
final events = await db.select(db.fieldTestEvents).get();
// print('Captured events: ${events.map((e) => e.eventType).toList()}');
expect(events.isNotEmpty, isTrue);
expect(events.any((e) => e.eventType.contains('START') || e.eventType.contains('RIDE')), isTrue);
});
test('RideStateMachine assigns stable cloud identity at ride creation', () async {
final bike = GarageMotorcycle(
id: 7,
year: 2024,
makeId: 'triumph',
makeName: 'Triumph',
modelId: 'street-triple',
modelName: 'Street Triple',
variantName: 'RS',
isDefault: true,
dateAdded: DateTime.utc(2026, 9, 1),
lastUpdated: DateTime.utc(2026, 9, 1),
);
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
defaultMotorcycleProvider.overrideWithValue(bike),
],
);
addTearDown(container.dispose);
final notifier = container.read(rideStateMachineProvider.notifier);
await notifier.startRide();
final session = container.read(rideStateMachineProvider);
expect(session.dbId, isNotNull);
final ride = await (db.select(db.rides)..where((r) => r.id.equals(session.dbId!))).getSingle();
expect(ride.cloudRideId, isNotNull);
expect(ride.cloudRideId, startsWith('ride_'));
expect(ride.motorcycleId, bike.id);
expect(ride.motorcycleYear, bike.year);
expect(ride.motorcycleMake, bike.makeName);
expect(ride.motorcycleModel, bike.modelName);
expect(ride.motorcycleVariant, bike.variantName);
final ensured = await db.ensureCloudRideId(ride.id);
expect(ensured, ride.cloudRideId);
expect(await db.getCloudRideId(ride.id), ride.cloudRideId);
});
test('Interrupted ride recovery preserves existing cloud identity', () async {
final rideId = await db.startRide(startTime: DateTime.utc(2026, 9, 2, 12));
final cloudRideId = await db.getCloudRideId(rideId);
expect(cloudRideId, isNotNull);
await db.into(db.telemetrySamples).insert(
TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: DateTime.utc(2026, 9, 2, 12, 5),
),
);
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
container.read(rideStateMachineProvider.notifier);
await Future<void>.delayed(const Duration(milliseconds: 50));
final recovered = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle();
expect(
recovered.endTime!.isAtSameMomentAs(DateTime.utc(2026, 9, 2, 12, 5)),
isTrue,
);
expect(recovered.cloudRideId, cloudRideId);
expect(await db.ensureCloudRideId(rideId), cloudRideId);
});
test('Motion Safeguard: Moving vehicle auto-promotes READY state to active recording', () async {
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
databaseProvider.overrideWithValue(db),
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
audioAlertServiceProvider.overrideWithValue(mockAudio),
fieldTestLoggerProvider.overrideWithValue(logger),
],
);
addTearDown(container.dispose);
final notifier = container.read(rideStateMachineProvider.notifier);
notifier.setReady();
var session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.ready));
expect(session.dbId, isNull);
// Emit movement sample (speed = 25 MPH)
fakeTelemetry.emitSample(
TelemetrySample(
timestamp: DateTime.now(),
latitude: 34.2570,
longitude: -85.1647,
speed: 25.0,
heading: 90.0,
gpsAccuracy: 3.0,
),
);
await Future.delayed(const Duration(milliseconds: 50));
// Verify auto-start promoted to RIDING with assigned dbId
session = container.read(rideStateMachineProvider);
expect(session.state, equals(RideState.riding));
expect(session.dbId, isNotNull);
});
test('Recording Health Contract validates prerequisites strictly', () {
const armed = RecordingHealthContract(
status: RecordingHealthStatus.recordingArmed,
isForegroundServiceAlive: false,
isTelemetryPipelineActive: false,
);
expect(armed.canReportRecording, isFalse);
const active = RecordingHealthContract(
status: RecordingHealthStatus.recordingActive,
isForegroundServiceAlive: true,
isTelemetryPipelineActive: true,
isAtLeastOneSensorActive: true,
isDatabasePersistenceOperational: true,
isValidSessionId: true,
);
expect(active.canReportRecording, isTrue);
});
}