MotorSignal-App/test/swarm_road_alert_test.dart

609 lines
21 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 writes vote to subcollection and increments confirmations in memory', () async {
final report = await service.reportAlert(
category: RoadAlertCategory.police,
latitude: 34.0,
longitude: -118.0,
);
final result = await service.confirmAlert(report.id, voterUid: 'rider_trailing');
expect(result, isTrue);
final voteDoc = await fakeFirestore
.collection('road_alerts')
.doc(report.id)
.collection('votes')
.doc('rider_trailing')
.get();
expect(voteDoc.exists, isTrue);
expect(voteDoc.data()!['type'], 'confirm');
expect(voteDoc.data()!['voterUid'], 'rider_trailing');
expect(service.getCachedReport(report.id)?.confirmations, 2);
});
test('clearAlert writes dismissal vote to subcollection 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 and stores vote in subcollection
final res1 = await service.clearAlert(report.id, voterUid: 'rider_a');
expect(res1, isTrue);
final voteA = await fakeFirestore
.collection('road_alerts')
.doc(report.id)
.collection('votes')
.doc('rider_a')
.get();
expect(voteA.exists, isTrue);
expect(voteA.data()!['type'], 'dismiss');
expect(service.getCachedReport(report.id)?.dismissals, 1);
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
expect(res2, isTrue);
final voteB = await fakeFirestore
.collection('road_alerts')
.doc(report.id)
.collection('votes')
.doc('rider_b')
.get();
expect(voteB.exists, isTrue);
expect(voteB.data()!['type'], 'dismiss');
expect(service.getCachedReport(report.id)?.dismissals, 2);
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared);
});
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);
});
});
group('Native Road Alert Categories & Verification Trust Tests', () {
test('all 10 native categories map properly to RoadAlert', () {
final now = DateTime.now();
final expires = now.add(const Duration(hours: 2));
final categories = [
RoadAlertCategory.police,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.weatherHazard,
];
for (final cat in categories) {
final report = SwarmReport(
id: 'report_${cat.name}',
category: cat,
subtype: 'severe_${cat.name}',
latitude: 35.0,
longitude: -84.0,
description: 'Rider alert for ${cat.name}',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 1,
dismissals: 0,
source: 'MotorSignal Swarm',
status: RoadAlertStatus.active,
);
final alert = report.toRoadAlert();
expect(alert.category, cat);
expect(alert.subtype, 'severe_${cat.name}');
expect(alert.isCommunityConfirmed, isFalse);
expect(alert.confirmations, 1);
expect(alert.dismissals, 0);
expect(alert.status, RoadAlertStatus.active);
}
});
test('isCommunityConfirmed requires at least 2 confirmations', () {
final now = DateTime.now();
final expires = now.add(const Duration(hours: 2));
final unconfirmed = SwarmReport(
id: 'rep_1',
category: RoadAlertCategory.pothole,
latitude: 35.0,
longitude: -84.0,
description: 'Pot hole test',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 1,
dismissals: 0,
).toRoadAlert();
expect(unconfirmed.isCommunityConfirmed, isFalse);
final confirmed = SwarmReport(
id: 'rep_2',
category: RoadAlertCategory.pothole,
latitude: 35.0,
longitude: -84.0,
description: 'Pot hole test',
reporterUid: 'rider_1',
reportedAt: now,
expiresAt: expires,
confirmations: 2,
dismissals: 0,
).toRoadAlert();
expect(confirmed.isCommunityConfirmed, isTrue);
});
});
group('Rate Limiting & Duplicate Detection Tests', () {
late FakeFirebaseFirestore fakeFirestore;
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
});
test('enforces rate limit interval and throws RoadAlertRateLimitException', () async {
final rateLimitedService = SwarmRoadAlertService(
firestore: fakeFirestore,
minReportInterval: const Duration(seconds: 15),
);
// First report succeeds
final first = await rateLimitedService.reportAlert(
category: RoadAlertCategory.police,
latitude: 35.0,
longitude: -84.0,
);
expect(first.id, startsWith('swarm_'));
// Second immediate report at distant location throws rate limit exception
expect(
() => rateLimitedService.reportAlert(
category: RoadAlertCategory.accident,
latitude: 36.0,
longitude: -85.0,
),
throwsA(isA<RoadAlertRateLimitException>().having(
(e) => e.retryAfterSeconds,
'retryAfterSeconds',
inInclusiveRange(1, 15),
)),
);
// With ignoreRateLimit: true, report succeeds
final bypassReport = await rateLimitedService.reportAlert(
category: RoadAlertCategory.accident,
latitude: 36.0,
longitude: -85.0,
ignoreRateLimit: true,
);
expect(bypassReport.id, startsWith('swarm_'));
});
test('duplicate reports within 200m and 15min auto-confirm existing alert', () async {
final service = SwarmRoadAlertService(
firestore: fakeFirestore,
minReportInterval: Duration.zero,
duplicateDistanceMeters: 200.0,
duplicateWindow: const Duration(minutes: 15),
);
// Rider 1 reports a pothole at lat 35.0000, lng -84.0000
final first = await service.reportAlert(
category: RoadAlertCategory.pothole,
latitude: 35.0000,
longitude: -84.0000,
description: 'Deep rim bender',
reporterUid: 'rider_1',
);
expect(first.confirmations, 1);
// Rider 2 reports a pothole 50m away (lat 35.0004, lng -84.0000)
final duplicateMerged = await service.reportAlert(
category: RoadAlertCategory.pothole,
latitude: 35.0004,
longitude: -84.0000,
description: 'Bad crater in right lane',
reporterUid: 'rider_2',
);
// Auto-confirmed: ID matches original, confirmations incremented to 2
expect(duplicateMerged.id, first.id);
expect(duplicateMerged.confirmations, 2);
// Check vote subcollection has confirmation vote from rider_2
final voteDoc = await fakeFirestore
.collection('road_alerts')
.doc(first.id)
.collection('votes')
.doc('rider_2')
.get();
expect(voteDoc.exists, isTrue);
expect(voteDoc.data()!['type'], 'confirm');
expect(service.getCachedReport(first.id)?.confirmations, 2);
// Distant report (500m away: lat 35.0050) creates a separate alert
final separate = await service.reportAlert(
category: RoadAlertCategory.pothole,
latitude: 35.0050,
longitude: -84.0000,
);
expect(separate.id, isNot(first.id));
expect(separate.confirmations, 1);
});
test('per-user voting restrictions prevent self-voting and repeat voting', () async {
final service = SwarmRoadAlertService(firestore: fakeFirestore);
final report = await service.reportAlert(
category: RoadAlertCategory.debris,
latitude: 35.0,
longitude: -84.0,
);
// Reporter cannot vote on their own alert
expect(service.canUserVote(report.id, report.reporterUid), isFalse);
final selfVote = await service.confirmAlert(report.id, voterUid: report.reporterUid);
expect(selfVote, isFalse);
// Another user can vote once
const otherUser = 'rider_trail_42';
expect(service.canUserVote(report.id, otherUser), isTrue);
expect(service.hasUserVoted(report.id, otherUser), isFalse);
final firstVote = await service.confirmAlert(report.id, voterUid: otherUser);
expect(firstVote, isTrue);
expect(service.hasUserVoted(report.id, otherUser), isTrue);
expect(service.canUserVote(report.id, otherUser), isFalse);
// Repeat vote is rejected
final repeatVote = await service.confirmAlert(report.id, voterUid: otherUser);
expect(repeatVote, isFalse);
});
test('cleared alerts are marked cleared and pruned from nearby queries', () async {
final service = SwarmRoadAlertService(firestore: fakeFirestore);
final report = await service.reportAlert(
category: RoadAlertCategory.hazard,
latitude: 35.1,
longitude: -84.1,
);
// Query finds it while active
var nearby = await service.getAlertsNear(
latitude: 35.1,
longitude: -84.1,
radiusMeters: 1000,
);
expect(nearby.length, 1);
// Two dismissals from different riders
await service.clearAlert(report.id, voterUid: 'voter_a');
await service.clearAlert(report.id, voterUid: 'voter_b');
// Now marked cleared / expired
final cached = service.getCachedReport(report.id);
expect(cached, isNotNull);
expect(cached!.status, RoadAlertStatus.cleared);
// Query prunes cleared alerts
nearby = await service.getAlertsNear(
latitude: 35.1,
longitude: -84.1,
radiusMeters: 1000,
);
expect(nearby, isEmpty);
});
});
}