344 lines
12 KiB
Dart
344 lines
12 KiB
Dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
|
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
|
|
import 'package:motorcycle_dash/services/alerts/swarm_road_alert_models.dart';
|
|
import 'package:motorcycle_dash/services/alerts/swarm_road_alert_provider.dart';
|
|
import 'package:motorcycle_dash/services/alerts/swarm_road_alert_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
setUp(() {
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
group('SwarmReport Model Tests', () {
|
|
test('roundtrip serialization to/from JSON', () {
|
|
final now = DateTime.now();
|
|
final expires = now.add(const Duration(hours: 1));
|
|
|
|
final report = SwarmReport(
|
|
id: 'swarm_test_1',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 35.3934,
|
|
longitude: -83.9261,
|
|
heading: 180.0,
|
|
roadName: 'US-129 Deals Gap',
|
|
description: 'Tennessee Highway Patrol stationary radar',
|
|
reporterUid: 'rider_abc',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
confirmations: 3,
|
|
dismissals: 1,
|
|
groupRideId: 'group_ride_123',
|
|
);
|
|
|
|
final json = report.toJson();
|
|
expect(json['id'], 'swarm_test_1');
|
|
expect(json['category'], 'police');
|
|
expect(json['latitude'], 35.3934);
|
|
expect(json['longitude'], -83.9261);
|
|
expect(json['confirmations'], 3);
|
|
expect(json['dismissals'], 1);
|
|
expect(json['groupRideId'], 'group_ride_123');
|
|
|
|
final fromJson = SwarmReport.fromJson(json);
|
|
expect(fromJson.id, report.id);
|
|
expect(fromJson.category, RoadAlertCategory.police);
|
|
expect(fromJson.latitude, 35.3934);
|
|
expect(fromJson.longitude, -83.9261);
|
|
expect(fromJson.confirmations, 3);
|
|
expect(fromJson.dismissals, 1);
|
|
expect(fromJson.groupRideId, 'group_ride_123');
|
|
});
|
|
|
|
test('confidence scoring based on confirmations and dismissals', () {
|
|
final now = DateTime.now();
|
|
final expires = now.add(const Duration(hours: 1));
|
|
|
|
// 3 confirmations, 1 dismissal -> 3/4 = 0.75
|
|
final r1 = SwarmReport(
|
|
id: '1',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 35.0,
|
|
longitude: -83.0,
|
|
description: 'Test',
|
|
reporterUid: 'u1',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
confirmations: 3,
|
|
dismissals: 1,
|
|
);
|
|
expect(r1.confidence, closeTo(0.75, 0.001));
|
|
|
|
// 1 confirmation, 0 dismissals -> 1/1 = 1.0
|
|
final r2 = SwarmReport(
|
|
id: '2',
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 35.0,
|
|
longitude: -83.0,
|
|
description: 'Test',
|
|
reporterUid: 'u1',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
confirmations: 1,
|
|
dismissals: 0,
|
|
);
|
|
expect(r2.confidence, 1.0);
|
|
|
|
// Clamp minimum
|
|
final r3 = SwarmReport(
|
|
id: '3',
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 35.0,
|
|
longitude: -83.0,
|
|
description: 'Test',
|
|
reporterUid: 'u1',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
confirmations: 0,
|
|
dismissals: 10,
|
|
);
|
|
expect(r3.confidence, 0.1);
|
|
});
|
|
|
|
test('toRoadAlert maps severity and source attributes properly', () {
|
|
final now = DateTime.now();
|
|
final expires = now.add(const Duration(hours: 1));
|
|
|
|
final policeReport = SwarmReport(
|
|
id: 'police_1',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 35.39,
|
|
longitude: -83.92,
|
|
description: 'Speed trap',
|
|
reporterUid: 'uid1',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
);
|
|
|
|
final policeAlert = policeReport.toRoadAlert(distanceFromUser: 250.0);
|
|
expect(policeAlert.providerId, 'swarm_alerts');
|
|
expect(policeAlert.severity, RoadAlertSeverity.high);
|
|
expect(policeAlert.source, 'MotorSignal Swarm');
|
|
expect(policeAlert.sourceType, RoadAlertSourceType.userReported);
|
|
expect(policeAlert.distanceFromUser, 250.0);
|
|
|
|
final packReport = SwarmReport(
|
|
id: 'hazard_1',
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 35.39,
|
|
longitude: -83.92,
|
|
description: 'Gravel in corner',
|
|
reporterUid: 'uid2',
|
|
reportedAt: now,
|
|
expiresAt: expires,
|
|
groupRideId: 'pack_77',
|
|
);
|
|
|
|
final packAlert = packReport.toRoadAlert();
|
|
expect(packAlert.severity, RoadAlertSeverity.medium);
|
|
expect(packAlert.source, 'Pack Rider Report');
|
|
});
|
|
|
|
test('isExpired detects when TTL has lapsed', () {
|
|
final past = DateTime.now().subtract(const Duration(minutes: 5));
|
|
final report = SwarmReport(
|
|
id: 'expired_1',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 35.0,
|
|
longitude: -83.0,
|
|
description: 'Old trap',
|
|
reporterUid: 'u1',
|
|
reportedAt: past.subtract(const Duration(hours: 1)),
|
|
expiresAt: past,
|
|
);
|
|
expect(report.isExpired, isTrue);
|
|
});
|
|
});
|
|
|
|
group('SwarmRoadAlertService Cloud & Cache Tests', () {
|
|
late FakeFirebaseFirestore fakeFirestore;
|
|
late SwarmRoadAlertService service;
|
|
|
|
setUp(() {
|
|
fakeFirestore = FakeFirebaseFirestore();
|
|
service = SwarmRoadAlertService(firestore: fakeFirestore);
|
|
});
|
|
|
|
test('reportAlert saves to Cloud Firestore and caches in memory', () async {
|
|
final report = await service.reportAlert(
|
|
category: RoadAlertCategory.police,
|
|
latitude: 34.0522,
|
|
longitude: -118.2437,
|
|
roadName: 'Mulholland Drive',
|
|
description: 'Radar trap near lookout',
|
|
);
|
|
|
|
expect(report.id, startsWith('swarm_'));
|
|
expect(report.category, RoadAlertCategory.police);
|
|
expect(report.confirmations, 1);
|
|
expect(report.dismissals, 0);
|
|
|
|
// Check Firestore doc
|
|
final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
|
expect(doc.exists, isTrue);
|
|
expect(doc.data()!['category'], 'police');
|
|
expect(doc.data()!['latitude'], 34.0522);
|
|
expect(doc.data()!['confirmations'], 1);
|
|
|
|
// Default TTL for police is 60 minutes
|
|
final reportedAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['reportedAt'] as int);
|
|
final expiresAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['expiresAt'] as int);
|
|
expect(expiresAt.difference(reportedAt).inMinutes, 60);
|
|
});
|
|
|
|
test('reportAlert hazard defaults to 120 minutes TTL', () async {
|
|
final report = await service.reportAlert(
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 35.0,
|
|
longitude: -83.0,
|
|
description: 'Gravel patch',
|
|
);
|
|
|
|
final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
|
final reportedAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['reportedAt'] as int);
|
|
final expiresAt = DateTime.fromMillisecondsSinceEpoch(doc.data()!['expiresAt'] as int);
|
|
expect(expiresAt.difference(reportedAt).inMinutes, 120);
|
|
});
|
|
|
|
test('confirmAlert increments confirmations in memory and Firestore', () async {
|
|
final report = await service.reportAlert(
|
|
category: RoadAlertCategory.police,
|
|
latitude: 34.0,
|
|
longitude: -118.0,
|
|
);
|
|
|
|
await service.confirmAlert(report.id);
|
|
|
|
final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
|
expect(doc.data()!['confirmations'], 2);
|
|
});
|
|
|
|
test('clearAlert increments dismissals and auto-expires after 2 dismissals', () async {
|
|
final report = await service.reportAlert(
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 34.0,
|
|
longitude: -118.0,
|
|
);
|
|
|
|
// 1st clear: increments dismissals to 1
|
|
await service.clearAlert(report.id);
|
|
var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
|
expect(doc.data()!['dismissals'], 1);
|
|
|
|
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
|
await service.clearAlert(report.id);
|
|
doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
|
expect(doc.data()!['dismissals'], 2);
|
|
final expiresAt = doc.data()!['expiresAt'] as int;
|
|
expect(DateTime.now().millisecondsSinceEpoch >= expiresAt - 1000, isTrue);
|
|
});
|
|
|
|
test('getAlertsNear finds alerts within radius and excludes distant ones', () async {
|
|
// 1. Report alert near Downtown LA (lat: 34.05, lng: -118.25)
|
|
await service.reportAlert(
|
|
category: RoadAlertCategory.police,
|
|
latitude: 34.0500,
|
|
longitude: -118.2500,
|
|
description: 'Downtown trap',
|
|
);
|
|
|
|
// 2. Report alert in San Francisco (distant: > 500 km)
|
|
await service.reportAlert(
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 37.7749,
|
|
longitude: -122.4194,
|
|
description: 'SF hazard',
|
|
);
|
|
|
|
// Query around Downtown LA with 2000 meter radius
|
|
final nearbyAlerts = await service.getAlertsNear(
|
|
latitude: 34.0505,
|
|
longitude: -118.2505,
|
|
radiusMeters: 2000,
|
|
);
|
|
|
|
expect(nearbyAlerts.length, 1);
|
|
expect(nearbyAlerts.first.category, RoadAlertCategory.police);
|
|
expect(nearbyAlerts.first.description, 'Downtown trap');
|
|
expect(nearbyAlerts.first.distanceFromUser, isNotNull);
|
|
expect(nearbyAlerts.first.distanceFromUser!, lessThan(2000));
|
|
});
|
|
|
|
test('offline fallback works seamlessly without Cloud Firestore', () async {
|
|
final offlineService = SwarmRoadAlertService(firestore: null, auth: null);
|
|
expect(offlineService.isCloudAvailable, isFalse);
|
|
|
|
final report = await offlineService.reportAlert(
|
|
category: RoadAlertCategory.accident,
|
|
latitude: 35.39,
|
|
longitude: -83.92,
|
|
description: 'Offline crash report',
|
|
);
|
|
|
|
expect(report.id, startsWith('swarm_'));
|
|
|
|
final alerts = await offlineService.getAlertsNear(
|
|
latitude: 35.39,
|
|
longitude: -83.92,
|
|
radiusMeters: 1000,
|
|
);
|
|
|
|
expect(alerts.length, 1);
|
|
expect(alerts.first.category, RoadAlertCategory.accident);
|
|
});
|
|
});
|
|
|
|
group('SwarmRoadAlertProvider Tests', () {
|
|
test('implements RoadAlertProvider contract correctly', () async {
|
|
final service = SwarmRoadAlertService(firestore: null, auth: null);
|
|
final provider = SwarmRoadAlertProvider(service);
|
|
|
|
expect(provider.id, 'swarm_alerts');
|
|
expect(provider.name, 'MotorSignal Rider Swarm');
|
|
expect(provider.supportedCategories.contains(RoadAlertCategory.police), isTrue);
|
|
expect(provider.supportedCategories.contains(RoadAlertCategory.hazard), isTrue);
|
|
|
|
final status = await provider.testConnection('');
|
|
expect(status, RoadAlertProviderStatus.connected);
|
|
});
|
|
});
|
|
|
|
group('Riverpod Swarm Provider Wiring Tests', () {
|
|
test('activeRoadAlertProviders respects swarmAlertsEnabledProvider toggle', () async {
|
|
final container = ProviderContainer();
|
|
addTearDown(container.dispose);
|
|
|
|
// Default is enabled
|
|
final initialEnabled = container.read(swarmAlertsEnabledProvider);
|
|
expect(initialEnabled, isTrue);
|
|
|
|
var activeProviders = container.read(activeRoadAlertProviders);
|
|
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue);
|
|
|
|
// Disable swarm alerts
|
|
await container.read(swarmAlertsEnabledProvider.notifier).setEnabled(false);
|
|
expect(container.read(swarmAlertsEnabledProvider), isFalse);
|
|
|
|
activeProviders = container.read(activeRoadAlertProviders);
|
|
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isFalse);
|
|
|
|
// Re-enable
|
|
await container.read(swarmAlertsEnabledProvider.notifier).setEnabled(true);
|
|
expect(container.read(swarmAlertsEnabledProvider), isTrue);
|
|
|
|
activeProviders = container.read(activeRoadAlertProviders);
|
|
expect(activeProviders.any((p) => p.id == 'swarm_alerts'), isTrue);
|
|
});
|
|
});
|
|
}
|