362 lines
13 KiB
Dart
362 lines
13 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('pause and resume preserve ride identity and persist only active samples', () async {
|
|
final container = ProviderContainer(overrides: [
|
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
databaseProvider.overrideWithValue(db),
|
|
telemetryServiceProvider.overrideWithValue(fakeTelemetry),
|
|
audioAlertServiceProvider.overrideWithValue(mockAudio),
|
|
fieldTestLoggerProvider.overrideWithValue(logger),
|
|
]);
|
|
addTearDown(container.dispose);
|
|
final machine = container.read(rideStateMachineProvider.notifier);
|
|
await machine.startRide();
|
|
final rideId = container.read(rideStateMachineProvider).dbId!;
|
|
final cloudId = await db.getCloudRideId(rideId);
|
|
Future<void> emit(double speed) async {
|
|
fakeTelemetry.emitSample(TelemetrySample(
|
|
timestamp: DateTime.now(), latitude: 34, longitude: -85,
|
|
speed: speed, gpsAccuracy: 3,
|
|
));
|
|
await Future<void>.delayed(const Duration(milliseconds: 70));
|
|
}
|
|
await emit(10);
|
|
machine.pauseRide();
|
|
expect(container.read(rideStateMachineProvider).state, RideState.paused);
|
|
await emit(99);
|
|
machine.resumeRide();
|
|
expect(container.read(rideStateMachineProvider).state, RideState.riding);
|
|
expect(container.read(rideStateMachineProvider).dbId, rideId);
|
|
await emit(20);
|
|
await machine.finishRide();
|
|
await logger.flush();
|
|
final samples = await db.select(db.telemetrySamples).get();
|
|
expect(samples.map((sample) => sample.speed), [10, 20]);
|
|
expect(await db.getCloudRideId(rideId), cloudId);
|
|
final rides = await db.select(db.rides).get();
|
|
expect(rides, hasLength(1));
|
|
expect(rides.single.endTime, 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);
|
|
});
|
|
}
|