291 lines
9.9 KiB
Dart
291 lines
9.9 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_compliance.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
group('RoadAlertCompliance Jurisdiction Evaluation Tests', () {
|
|
test('correctly classifies United States locations as standard', () {
|
|
// Los Angeles, CA
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(34.0522, -118.2437),
|
|
equals(AlertComplianceJurisdiction.standard),
|
|
);
|
|
// New York, NY
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(40.7128, -74.0060),
|
|
equals(AlertComplianceJurisdiction.standard),
|
|
);
|
|
// Deals Gap, NC / TN (Tail of the Dragon)
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(35.4712, -83.9234),
|
|
equals(AlertComplianceJurisdiction.standard),
|
|
);
|
|
});
|
|
|
|
test('correctly classifies Germany and Switzerland as prohibited', () {
|
|
// Berlin, Germany
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(52.5200, 13.4050),
|
|
equals(AlertComplianceJurisdiction.prohibited),
|
|
);
|
|
// Munich, Germany
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(48.1351, 11.5820),
|
|
equals(AlertComplianceJurisdiction.prohibited),
|
|
);
|
|
// Zurich, Switzerland
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(47.3769, 8.5417),
|
|
equals(AlertComplianceJurisdiction.prohibited),
|
|
);
|
|
// Geneva, Switzerland
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(46.2044, 6.1432),
|
|
equals(AlertComplianceJurisdiction.prohibited),
|
|
);
|
|
});
|
|
|
|
test('correctly classifies France as frenchDangerZone', () {
|
|
// Paris, France
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(48.8566, 2.3522),
|
|
equals(AlertComplianceJurisdiction.frenchDangerZone),
|
|
);
|
|
// Lyon, France
|
|
expect(
|
|
RoadAlertCompliance.evaluateJurisdiction(45.7640, 4.8357),
|
|
equals(AlertComplianceJurisdiction.frenchDangerZone),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('RoadAlertCompliance Alert Transformation Tests', () {
|
|
final now = DateTime.now();
|
|
final List<RoadAlert> testAlerts = [
|
|
RoadAlert(
|
|
id: 'cam_1',
|
|
providerId: 'here_safety_cameras',
|
|
category: RoadAlertCategory.speedCamera,
|
|
latitude: 48.8566,
|
|
longitude: 2.3522,
|
|
description: 'Fixed speed camera 50 km/h',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'HERE Technologies',
|
|
),
|
|
RoadAlert(
|
|
id: 'police_trap_1',
|
|
providerId: 'here_safety_cameras',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 52.5200,
|
|
longitude: 13.4050,
|
|
description: 'Mobile Radar Speed Trap',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'HERE Technologies',
|
|
),
|
|
RoadAlert(
|
|
id: 'hazard_1',
|
|
providerId: 'swarm_alerts',
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 52.5200,
|
|
longitude: 13.4050,
|
|
description: 'Gravel on road',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'MotorSignal Rider Swarm',
|
|
),
|
|
RoadAlert(
|
|
id: 'accident_1',
|
|
providerId: 'tomtom',
|
|
category: RoadAlertCategory.accident,
|
|
latitude: 48.8566,
|
|
longitude: 2.3522,
|
|
description: 'Multi-vehicle collision',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'TomTom',
|
|
),
|
|
];
|
|
|
|
test('Germany / Switzerland: speed camera and radar trap suppressed; hazards preserved', () {
|
|
final filtered = RoadAlertCompliance.applyCompliance(
|
|
testAlerts,
|
|
userLat: 52.5200,
|
|
userLng: 13.4050,
|
|
complianceEnabled: true,
|
|
);
|
|
|
|
// Speed camera and radar trap must be filtered out
|
|
expect(filtered.any((a) => a.id == 'cam_1'), isFalse);
|
|
expect(filtered.any((a) => a.id == 'police_trap_1'), isFalse);
|
|
|
|
// Hazards and accidents must remain
|
|
expect(filtered.any((a) => a.id == 'hazard_1'), isTrue);
|
|
expect(filtered.any((a) => a.id == 'accident_1'), isTrue);
|
|
});
|
|
|
|
test('France: speed camera converted into Danger Zone hazard advisory', () {
|
|
final filtered = RoadAlertCompliance.applyCompliance(
|
|
testAlerts,
|
|
userLat: 48.8566,
|
|
userLng: 2.3522,
|
|
complianceEnabled: true,
|
|
);
|
|
|
|
final camAlert = filtered.firstWhere((a) => a.id == 'cam_1');
|
|
expect(camAlert.category, equals(RoadAlertCategory.hazard));
|
|
expect(camAlert.description, contains('Zone de Danger'));
|
|
});
|
|
|
|
test('United States: full exact alerts preserved with 100% fidelity', () {
|
|
final usAlerts = RoadAlertCompliance.applyCompliance(
|
|
testAlerts,
|
|
userLat: 34.0522,
|
|
userLng: -118.2437,
|
|
complianceEnabled: true,
|
|
);
|
|
|
|
expect(usAlerts.length, equals(testAlerts.length));
|
|
expect(usAlerts.firstWhere((a) => a.id == 'cam_1').category, equals(RoadAlertCategory.speedCamera));
|
|
expect(usAlerts.firstWhere((a) => a.id == 'police_trap_1').category, equals(RoadAlertCategory.police));
|
|
});
|
|
|
|
test('When compliance is disabled, alerts pass through unmodified', () {
|
|
final passThrough = RoadAlertCompliance.applyCompliance(
|
|
testAlerts,
|
|
userLat: 52.5200, // Germany
|
|
userLng: 13.4050,
|
|
complianceEnabled: false,
|
|
);
|
|
|
|
expect(passThrough.length, equals(testAlerts.length));
|
|
expect(passThrough.any((a) => a.id == 'cam_1'), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Phase 4 Riverpod State Management Tests', () {
|
|
test('roadAlertAudioModeProvider defaults to voice and switches to tacticalTone', () async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
expect(container.read(roadAlertAudioModeProvider), equals(RoadAlertAudioMode.voice));
|
|
|
|
await container
|
|
.read(roadAlertAudioModeProvider.notifier)
|
|
.setMode(RoadAlertAudioMode.tacticalTone);
|
|
expect(container.read(roadAlertAudioModeProvider), equals(RoadAlertAudioMode.tacticalTone));
|
|
});
|
|
|
|
test('leanInterlockEnabledProvider defaults to true and toggles properly', () async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
expect(container.read(leanInterlockEnabledProvider), isTrue);
|
|
|
|
await container.read(leanInterlockEnabledProvider.notifier).setEnabled(false);
|
|
expect(container.read(leanInterlockEnabledProvider), isFalse);
|
|
});
|
|
|
|
test('jurisdictionComplianceEnabledProvider defaults to true and toggles properly', () async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
expect(container.read(jurisdictionComplianceEnabledProvider), isTrue);
|
|
|
|
await container.read(jurisdictionComplianceEnabledProvider.notifier).setEnabled(false);
|
|
expect(container.read(jurisdictionComplianceEnabledProvider), isFalse);
|
|
});
|
|
});
|
|
|
|
group('Lean-Angle Cornering Safety Interlock Tests', () {
|
|
test('RoadAlertService tracks lean angle and cornering state correctly', () async {
|
|
final leanController = StreamController<double>.broadcast();
|
|
addTearDown(leanController.close);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
leanStreamOverrideProvider.overrideWithValue(leanController.stream),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final service = container.read(roadAlertServiceProvider);
|
|
|
|
// Initial lean is 0 (upright)
|
|
expect(service.currentLeanAngle, equals(0.0));
|
|
expect(service.isCornering, isFalse);
|
|
|
|
// Lean into right turn: 22 degrees
|
|
leanController.add(22.0);
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
|
|
expect(service.currentLeanAngle, equals(22.0));
|
|
expect(service.isCornering, isTrue);
|
|
|
|
// Return to upright: 3 degrees
|
|
leanController.add(3.0);
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
|
|
// Just returned, but under 1.5s recovery gate: still marked cornering
|
|
expect(service.currentLeanAngle, equals(3.0));
|
|
expect(service.isCornering, isTrue);
|
|
|
|
// Wait 1.6 seconds for upright recovery
|
|
await Future.delayed(const Duration(milliseconds: 1600));
|
|
expect(service.isCornering, isFalse);
|
|
});
|
|
|
|
test('Alert audio is deferred when bike is cornering at 25 degrees', () async {
|
|
final leanController = StreamController<double>.broadcast();
|
|
addTearDown(leanController.close);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
leanStreamOverrideProvider.overrideWithValue(leanController.stream),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final service = container.read(roadAlertServiceProvider);
|
|
|
|
// Set bike cornering
|
|
leanController.add(25.0);
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
expect(service.isCornering, isTrue);
|
|
|
|
final now = DateTime.now();
|
|
// Inject alert 200m ahead
|
|
final alert = RoadAlert(
|
|
id: 'apex_trap_1',
|
|
providerId: 'here_safety_cameras',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 34.0522,
|
|
longitude: -118.2437,
|
|
description: 'Speed enforcement trap',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
distanceFromUser: 200.0,
|
|
source: 'HERE Technologies',
|
|
);
|
|
|
|
service.simulateAlert(alert);
|
|
await Future.delayed(const Duration(milliseconds: 50));
|
|
|
|
// Alert should be in cached alerts
|
|
expect(service.alertsStream, emitsThrough(predicate<List<RoadAlert>>((l) => l.isNotEmpty)));
|
|
});
|
|
});
|
|
}
|