MotorSignal-App/test/swarm_road_alert_test.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);
});
});
}