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.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.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.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 = []; 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.delayed(const Duration(milliseconds: 5)); fusionService.handleGyroscopeEvent(gyro(0.0, 0.0, 0.02)); fusionService.handleAccelerometerEvent(accel(0.0, g, 0.0)); await Future.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.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.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.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', ); }); }); }