280 lines
12 KiB
Dart
280 lines
12 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:motorcycle_dash/services/calibration_service.dart';
|
|
import 'package:motorcycle_dash/services/sensor_fusion_service.dart';
|
|
import 'package:sensors_plus/sensors_plus.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const double g = 9.80665;
|
|
|
|
AccelerometerEvent accel(double x, double y, double z) =>
|
|
AccelerometerEvent(x, y, z, DateTime(2026, 10, 15, 12, 0, 0));
|
|
|
|
GyroscopeEvent gyro(double x, double y, double z) =>
|
|
GyroscopeEvent(x, y, z, DateTime(2026, 10, 15, 12, 0, 0));
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
group('Sensor Calibration Lifecycle Regression Tests', () {
|
|
test('1. Calibration records gyroscope bias correctly and neutralizes steady drift', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService.dispose);
|
|
await fusionService.initializationDone;
|
|
|
|
// Feed upright portrait gravity and a steady gyro zero-rate offset on Z (-0.12 rad/s)
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.01, -0.02, -0.12));
|
|
|
|
await fusionService.calibrateUpright();
|
|
|
|
// Verify persisted offsets in CalibrationService
|
|
final savedOffsets = await calibrationService.loadCalibrationOffsets();
|
|
expect(savedOffsets['isCalibrated'], equals(1.0));
|
|
expect(savedOffsets['gyroBiasX'], closeTo(0.01, 1e-6));
|
|
expect(savedOffsets['gyroBiasY'], closeTo(-0.02, 1e-6));
|
|
expect(savedOffsets['gyroBiasZ'], closeTo(-0.12, 1e-6));
|
|
|
|
// Subsequent identical gyro event should produce 0 deg/s roll rate once dt > 0
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.01, -0.02, -0.12));
|
|
expect(fusionService.latestRollRateDegPerSec, closeTo(0.0, 1e-4));
|
|
});
|
|
|
|
test('2. Resetting calibration removes stale gyroscope bias from active calculations', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService.dispose);
|
|
await fusionService.initializationDone;
|
|
|
|
// Calibrate while a spurious gyro spike (-0.25 rad/s on Z) is present
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.25));
|
|
await fusionService.calibrateUpright();
|
|
|
|
// Reset calibration
|
|
await fusionService.resetCalibration();
|
|
expect(fusionService.isCalibrated, isFalse);
|
|
|
|
// Feed a true zero-rate gyro event (0, 0, 0) after a short tick so dt > 0.
|
|
// Active calculations must not subtract the stale -0.25 rad/s bias.
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, 0.0));
|
|
|
|
expect(
|
|
fusionService.latestRollRateDegPerSec,
|
|
closeTo(0.0, 0.01),
|
|
reason: 'Stale gyro bias remained active in SensorFusionService after resetCalibration()',
|
|
);
|
|
});
|
|
|
|
test('3. Clearing calibration removes persisted bias so it is not restored after service reinitialization', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService1 = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
await fusionService1.initializationDone;
|
|
|
|
// Save calibration with non-zero gyro biases (both global and motorcycleId: 7)
|
|
fusionService1.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService1.handleGyroscopeEvent(gyro(0.05, -0.05, -0.20));
|
|
await fusionService1.calibrateUpright(motorcycleId: 7);
|
|
|
|
// Clear calibration via resetCalibration
|
|
await fusionService1.resetCalibration(motorcycleId: 7);
|
|
fusionService1.dispose();
|
|
|
|
// Verify persisted gyro biases are cleared in SharedPreferences
|
|
final globalOffsets = await calibrationService.loadCalibrationOffsets();
|
|
expect(globalOffsets['isCalibrated'], equals(0.0));
|
|
expect(
|
|
globalOffsets['gyroBiasX'],
|
|
closeTo(0.0, 1e-6),
|
|
reason: 'Global gyroBiasX was not cleared by clearCalibration()',
|
|
);
|
|
expect(
|
|
globalOffsets['gyroBiasY'],
|
|
closeTo(0.0, 1e-6),
|
|
reason: 'Global gyroBiasY was not cleared by clearCalibration()',
|
|
);
|
|
expect(
|
|
globalOffsets['gyroBiasZ'],
|
|
closeTo(0.0, 1e-6),
|
|
reason: 'Global gyroBiasZ was not cleared by clearCalibration()',
|
|
);
|
|
|
|
final bikeOffsets = await calibrationService.loadCalibrationOffsets(motorcycleId: 7);
|
|
expect(
|
|
bikeOffsets['gyroBiasZ'],
|
|
closeTo(0.0, 1e-6),
|
|
reason: 'Per-motorcycle gyroBiasZ was not cleared by clearCalibration()',
|
|
);
|
|
|
|
// Reinitialize a fresh SensorFusionService and verify zero bias in active calculations
|
|
final fusionService2 = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService2.dispose);
|
|
await fusionService2.initializationDone;
|
|
|
|
fusionService2.handleGyroscopeEvent(gyro(0.0, 0.0, 0.0));
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService2.handleGyroscopeEvent(gyro(0.0, 0.0, 0.0));
|
|
|
|
expect(
|
|
fusionService2.latestRollRateDegPerSec,
|
|
closeTo(0.0, 0.01),
|
|
reason: 'Reinitialized SensorFusionService restored stale gyro bias after clearCalibration()',
|
|
);
|
|
});
|
|
|
|
test('4. Repeated calibration does not accumulate old offsets', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService.dispose);
|
|
await fusionService.initializationDone;
|
|
|
|
final emittedLeans = <double>[];
|
|
final sub = fusionService.leanAngleStream.listen(emittedLeans.add);
|
|
addTearDown(sub.cancel);
|
|
|
|
// Calibration 1 with bias -0.10 rad/s and slight X tilt
|
|
fusionService.handleAccelerometerEvent(accel(-0.8, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.10));
|
|
await fusionService.calibrateUpright();
|
|
|
|
// Calibration 2 with bias -0.04 rad/s and upright gravity
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.04));
|
|
await fusionService.calibrateUpright();
|
|
|
|
// Calibration 3 with bias +0.02 rad/s and upright gravity
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, 0.02));
|
|
await fusionService.calibrateUpright();
|
|
|
|
final offsets = await calibrationService.loadCalibrationOffsets();
|
|
// Should equal the latest bias (+0.02), not the sum (-0.12)
|
|
expect(offsets['gyroBiasZ'], closeTo(0.02, 1e-6));
|
|
expect(offsets['roll'], closeTo(0.0, 1e-6));
|
|
expect(offsets['pitch'], closeTo(0.0, 1e-6));
|
|
|
|
// Verify roll rate with latest bias (+0.02) is 0.0 deg/s and lean angle is 0.0 deg
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, 0.02));
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(fusionService.latestRollRateDegPerSec, closeTo(0.0, 1e-4));
|
|
expect(emittedLeans.last, closeTo(0.0, 0.1));
|
|
});
|
|
|
|
test('5. Motorcycle-specific calibration isolation preserves per-bike offsets', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService.dispose);
|
|
await fusionService.initializationDone;
|
|
|
|
// Calibrate Motorcycle 1 with -0.15 rad/s Z gyro bias
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.15));
|
|
await fusionService.calibrateUpright(motorcycleId: 1);
|
|
|
|
// Calibrate Motorcycle 2 with +0.08 rad/s Z gyro bias
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, 0.08));
|
|
await fusionService.calibrateUpright(motorcycleId: 2);
|
|
|
|
// Load Motorcycle 1 and verify its specific gyro bias is active
|
|
await fusionService.loadCalibrationForMotorcycle(1);
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.15));
|
|
expect(fusionService.latestRollRateDegPerSec, closeTo(0.0, 1e-4));
|
|
|
|
// Reset Motorcycle 2 only; Motorcycle 1 persisted calibration should remain intact
|
|
await fusionService.resetCalibration(motorcycleId: 2);
|
|
|
|
final bike2Offsets = await calibrationService.loadCalibrationOffsets(motorcycleId: 2);
|
|
expect(bike2Offsets['isCalibrated'], equals(0.0));
|
|
expect(bike2Offsets['gyroBiasZ'], closeTo(0.0, 1e-6));
|
|
|
|
final bike1Offsets = await calibrationService.loadCalibrationOffsets(motorcycleId: 1);
|
|
expect(bike1Offsets['isCalibrated'], equals(1.0));
|
|
expect(bike1Offsets['gyroBiasZ'], closeTo(-0.15, 1e-6));
|
|
|
|
// Reload Motorcycle 1 and verify it still neutralizes its -0.15 rad/s bias
|
|
await fusionService.loadCalibrationForMotorcycle(1);
|
|
expect(fusionService.isCalibrated, isTrue);
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.15));
|
|
expect(fusionService.latestRollRateDegPerSec, closeTo(0.0, 1e-4));
|
|
});
|
|
|
|
test('6. Automatic remount invalidation suspends calibration without wiping gyro bias on re-lock', () async {
|
|
final calibrationService = CalibrationService();
|
|
final fusionService = SensorFusionService(
|
|
calibrationService,
|
|
autoStartSensors: false,
|
|
);
|
|
addTearDown(fusionService.dispose);
|
|
await fusionService.initializationDone;
|
|
|
|
// Calibrate upright portrait with -0.10 rad/s Z gyro bias
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.10));
|
|
await fusionService.calibrateUpright();
|
|
expect(fusionService.isCalibrated, isTrue);
|
|
|
|
// Simulate placing phone flat on desk (90-degree pitch shift from portrait upright)
|
|
fusionService.handleAccelerometerEvent(accel(0.0, 0.0, g));
|
|
expect(
|
|
fusionService.isCalibrated,
|
|
isFalse,
|
|
reason: 'Immediate remount (90 deg shift) should invalidate runtime calibration',
|
|
);
|
|
|
|
// Persisted calibration in SharedPreferences must still exist (not wiped by temporary remount)
|
|
final persisted = await calibrationService.loadCalibrationOffsets();
|
|
expect(persisted['isCalibrated'], equals(1.0));
|
|
expect(persisted['gyroBiasZ'], closeTo(-0.10, 1e-6));
|
|
|
|
// Return phone to upright mount for 15 samples to trigger automatic re-lock
|
|
for (int i = 0; i < 15; i++) {
|
|
fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0));
|
|
}
|
|
expect(
|
|
fusionService.isCalibrated,
|
|
isTrue,
|
|
reason: 'Returning device to calibrated mount orientation should auto-relock calibration',
|
|
);
|
|
|
|
// Verify gyro bias (-0.10 rad/s) is still compensated after auto-relock
|
|
await Future<void>.delayed(const Duration(milliseconds: 5));
|
|
fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, -0.10));
|
|
expect(
|
|
fusionService.latestRollRateDegPerSec,
|
|
closeTo(0.0, 1e-4),
|
|
reason: 'Auto-relock after remount should retain calibrated gyro bias compensation',
|
|
);
|
|
});
|
|
});
|
|
}
|