import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.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_providers.dart'; import 'package:motorcycle_dash/services/alerts/zyla_road_alert_provider.dart'; import 'package:motorcycle_dash/services/alerts/ga511_road_alert_provider.dart'; import 'package:motorcycle_dash/services/alerts/multi_source_alert_deduplicator.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { SharedPreferences.setMockInitialValues({}); }); group('MultiSourceAlertDeduplicator Tests', () { final now = DateTime.now(); test('deduplicates co-located alerts of same category, prioritizing native swarm', () { final nativeAlert = RoadAlert( id: 'swarm_incident_1', providerId: 'swarm', category: RoadAlertCategory.police, latitude: 34.2560, longitude: -85.1650, description: 'Speed trap near Turner McCall', reportedAt: now.subtract(const Duration(minutes: 5)), lastUpdated: now, confidence: 0.8, source: 'MotorSignal Rider Swarm', sourceType: RoadAlertSourceType.userReported, confirmations: 3, dismissals: 0, reporterUid: 'user_alice', ); final zylaAlert = RoadAlert( id: 'zyla_waze_99', providerId: 'zyla_waze_4814', category: RoadAlertCategory.police, latitude: 34.2562, // ~25m away longitude: -85.1651, description: 'Police reported on Turner McCall Blvd', reportedAt: now.subtract(const Duration(minutes: 3)), lastUpdated: now, confidence: 0.7, source: 'Zyla Waze Data #4814', sourceType: RoadAlertSourceType.providerReported, confirmations: 1, dismissals: 0, ); final merged = MultiSourceAlertDeduplicator.deduplicate([nativeAlert, zylaAlert]); expect(merged.length, equals(1)); final canonical = merged.first; expect(canonical.id, equals('swarm_incident_1')); expect(canonical.sourceType, equals(RoadAlertSourceType.userReported)); expect(canonical.confirmations, equals(3)); expect(canonical.dismissals, equals(0)); expect(canonical.reporterUid, equals('user_alice')); expect(canonical.confidence, greaterThan(0.8)); // Corroboration boost expect(canonical.source, contains('MotorSignal Rider Swarm')); expect(canonical.source, contains('Zyla Waze Data #4814')); }); test('deduplicates between multiple external providers (Zyla + GA 511)', () { final ga511Alert = RoadAlert( id: 'ga511_closure_10', providerId: 'ga511_official', category: RoadAlertCategory.roadClosure, latitude: 33.7490, longitude: -84.3880, description: 'I-75 Northbound Closed for Construction', reportedAt: now.subtract(const Duration(hours: 1)), lastUpdated: now, confidence: 0.95, source: 'Georgia 511', sourceType: RoadAlertSourceType.providerReported, ); final zylaAlert = RoadAlert( id: 'zyla_closure_22', providerId: 'zyla_waze_4814', category: RoadAlertCategory.roadClosure, latitude: 33.7492, // ~30m away longitude: -84.3882, description: 'Road Closed on I-75', reportedAt: now.subtract(const Duration(minutes: 20)), lastUpdated: now, confidence: 0.75, source: 'Zyla Waze Data #4814', sourceType: RoadAlertSourceType.providerReported, ); final merged = MultiSourceAlertDeduplicator.deduplicate([ga511Alert, zylaAlert]); expect(merged.length, equals(1)); // Official agency is chosen as primary expect(merged.first.providerId, equals('ga511_official')); expect(merged.first.id, equals('ga511_closure_10')); expect(merged.first.confidence, greaterThanOrEqualTo(0.95)); expect(merged.first.source, contains('Georgia 511')); expect(merged.first.source, contains('Zyla Waze Data #4814')); }); test('does NOT treat sibling Waze feeds as independent corroboration', () { final zylaAlert = RoadAlert( id: 'zyla_waze_1', providerId: 'zyla_waze_4814', category: RoadAlertCategory.police, latitude: 34.2560, longitude: -85.1650, description: 'Police on Turner McCall', reportedAt: now.subtract(const Duration(minutes: 5)), lastUpdated: now, confidence: 0.75, source: 'Zyla Waze Data #4814', sourceType: RoadAlertSourceType.providerReported, ); final wazeOsintAlert = RoadAlert( id: 'waze_osint_1', providerId: 'waze_osint', category: RoadAlertCategory.police, latitude: 34.2561, // ~15m away longitude: -85.1651, description: 'Police report on Turner McCall', reportedAt: now.subtract(const Duration(minutes: 7)), lastUpdated: now, confidence: 0.70, source: 'Waze Community (Experimental OSINT)', sourceType: RoadAlertSourceType.providerReported, ); final merged = MultiSourceAlertDeduplicator.deduplicate([zylaAlert, wazeOsintAlert]); expect(merged.length, equals(1)); final canonical = merged.first; // Zyla (licensed feed) is chosen as primary over OSINT scraper expect(canonical.id, equals('zyla_waze_1')); // No confidence inflation because they are sibling feeds from the same origin family expect(canonical.confidence, equals(0.75)); // Attribution remains single authoritative Zyla Waze feed expect(canonical.source, equals('Zyla Waze Data #4814')); expect(canonical.source, isNot(contains('Waze Community (Experimental OSINT)'))); }); test('does NOT merge alerts of different categories at the same location', () { final policeAlert = RoadAlert( id: 'alert_p1', providerId: 'swarm', category: RoadAlertCategory.police, latitude: 34.2560, longitude: -85.1650, description: 'Police radar', reportedAt: now, lastUpdated: now, source: 'MotorSignal Swarm', ); final accidentAlert = RoadAlert( id: 'alert_a1', providerId: 'ga511_official', category: RoadAlertCategory.accident, latitude: 34.2560, longitude: -85.1650, description: 'Multi-vehicle crash', reportedAt: now, lastUpdated: now, source: 'Georgia 511', ); final result = MultiSourceAlertDeduplicator.deduplicate([policeAlert, accidentAlert]); expect(result.length, equals(2)); }); test('does NOT merge alerts farther apart than proximity threshold', () { final alert1 = RoadAlert( id: 'alert_1', providerId: 'swarm', category: RoadAlertCategory.hazard, latitude: 34.2500, longitude: -85.1600, description: 'Debris in roadway', reportedAt: now, lastUpdated: now, source: 'MotorSignal Swarm', ); final alert2 = RoadAlert( id: 'alert_2', providerId: 'zyla_waze_4814', category: RoadAlertCategory.hazard, latitude: 34.2550, // ~550m away longitude: -85.1600, description: 'Debris in roadway', reportedAt: now, lastUpdated: now, source: 'Zyla Waze Data #4814', ); final result = MultiSourceAlertDeduplicator.deduplicate([alert1, alert2]); expect(result.length, equals(2)); }); test('excludes dismissed alert IDs', () { final alert1 = RoadAlert( id: 'alert_dismissed', providerId: 'swarm', category: RoadAlertCategory.pothole, latitude: 34.2500, longitude: -85.1600, description: 'Deep pothole', reportedAt: now, lastUpdated: now, source: 'MotorSignal Swarm', ); final result = MultiSourceAlertDeduplicator.deduplicate( [alert1], dismissedIds: {'alert_dismissed'}, ); expect(result, isEmpty); }); }); group('ZylaRoadAlertProvider Tests', () { test('fetches and parses normalized alerts from backend adapter', () async { final mockClient = MockClient((request) async { expect(request.url.path, equals('/api/v1/alerts/zyla')); expect(request.url.queryParameters, contains('min_lat')); expect(request.url.queryParameters, contains('max_lat')); final responseBody = json.encode({ 'provider_id': 'zyla_waze_4814', 'source': 'Zyla Waze Data API #4814', 'alerts': [ { 'id': 'zyla_101', 'category': 'police', 'subtype': 'POLICE_VISIBLE', 'latitude': 34.2562, 'longitude': -85.1651, 'road_name': 'Turner McCall Blvd', 'description': 'Visible Police Speed Trap', 'reported_at_utc': '2026-10-10T12:00:00Z', 'expires_at_utc': '2026-10-10T12:45:00Z', 'confidence': 0.85, 'source': 'Zyla Waze Data #4814' }, { 'id': 'zyla_102', 'category': 'accident', 'subtype': 'ACCIDENT_MAJOR', 'latitude': 34.2580, 'longitude': -85.1670, 'road_name': 'Broad St', 'description': 'Major Accident blocking right lane', 'reported_at_utc': '2026-10-10T12:05:00Z', 'expires_at_utc': '2026-10-10T13:05:00Z', 'confidence': 0.90, 'source': 'Zyla Waze Data #4814' } ], 'count': 2, 'cached': false, 'attribution': 'Waze / Zyla Labs #4814' }); return http.Response(responseBody, 200, headers: {'content-type': 'application/json'}); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final alerts = await provider.getAlerts( latitude: 34.2560, longitude: -85.1650, radiusMeters: 5000, ); expect(alerts.length, equals(2)); expect(alerts[0].id, equals('zyla_101')); expect(alerts[0].category, equals(RoadAlertCategory.police)); expect(alerts[0].severity, equals(RoadAlertSeverity.high)); expect(alerts[0].sourceType, equals(RoadAlertSourceType.providerReported)); expect(alerts[1].id, equals('zyla_102')); expect(alerts[1].category, equals(RoadAlertCategory.accident)); expect(alerts[1].severity, equals(RoadAlertSeverity.high)); }); test('handles backend errors gracefully with empty list', () async { final mockClient = MockClient((request) async { return http.Response('{"detail": "Circuit breaker open"}', 503); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final alerts = await provider.getAlerts( latitude: 34.2560, longitude: -85.1650, radiusMeters: 5000, ); expect(alerts, isEmpty); }); test('testConnection checks health endpoint and returns connected', () async { final mockClient = MockClient((request) async { expect(request.url.path, equals('/api/v1/alerts/ops/health')); return http.Response( json.encode({ 'providers': [ {'provider_id': 'zyla_waze_4814', 'status': 'operational'}, ] }), 200, ); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final status = await provider.testConnection(''); expect(status, equals(RoadAlertProviderStatus.connected)); }); test('transmits Authorization bearer token when getAuthToken is configured', () async { String? capturedAuthHeader; final mockClient = MockClient((request) async { capturedAuthHeader = request.headers['authorization'] ?? request.headers['Authorization']; return http.Response( json.encode({ 'alerts': [], 'count': 0, 'cached': false, 'attribution': 'Waze / Zyla Labs #4814' }), 200, headers: {'content-type': 'application/json'}, ); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', getAuthToken: () async => 'firebase-test-id-token-12345', ); await provider.getAlerts( latitude: 34.2560, longitude: -85.1650, radiusMeters: 5000, ); expect(capturedAuthHeader, equals('Bearer firebase-test-id-token-12345')); }); test('testConnection returns authError on 401 Unauthorized', () async { final mockClient = MockClient((request) async { return http.Response( json.encode({'detail': 'Missing authentication credentials'}), 401, headers: {'content-type': 'application/json'}, ); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final status = await provider.testConnection(''); expect(status, equals(RoadAlertProviderStatus.authError)); }); test('testConnection returns authError when getAuthToken returns null and no apiKey provided', () async { final mockClient = MockClient((request) async { return http.Response( json.encode({'providers': []}), 200, headers: {'content-type': 'application/json'}, ); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', getAuthToken: () async => null, ); final status = await provider.testConnection(''); expect(status, equals(RoadAlertProviderStatus.authError)); }); test('getAlerts handles 401 Unauthorized gracefully by returning empty list', () async { final mockClient = MockClient((request) async { return http.Response( json.encode({'detail': 'Invalid authentication token'}), 401, headers: {'content-type': 'application/json'}, ); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final alerts = await provider.getAlerts( latitude: 34.2560, longitude: -85.1650, radiusMeters: 5000, ); expect(alerts, isEmpty); }); test('preserves honest 0.0 confidence and null confidence without synthetic 0.7 inflation', () async { final mockClient = MockClient((request) async { final responseBody = json.encode({ 'provider_id': 'zyla_waze_4814', 'source': 'Zyla Waze Data #4814', 'alerts': [ { 'id': 'zyla_zero_conf', 'category': 'police', 'subtype': 'POLICE_VISIBLE', 'latitude': 34.2560, 'longitude': -85.1650, 'road_name': 'Martha Berry Hwy', 'description': 'Radar Trap', 'reported_at_utc': '2026-10-10T12:00:00Z', 'confidence': 0.0, 'source': 'Zyla Waze Data #4814' }, { 'id': 'zyla_null_conf', 'category': 'hazard', 'subtype': 'HAZARD_ON_ROAD', 'latitude': 34.2570, 'longitude': -85.1660, 'road_name': 'Turner McCall Blvd', 'description': 'Road Obstacle', 'reported_at_utc': '2026-10-10T12:01:00Z', 'confidence': null, 'source': 'Zyla Waze Data #4814' } ], 'count': 2, }); return http.Response(responseBody, 200, headers: {'content-type': 'application/json'}); }); final provider = ZylaRoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final alerts = await provider.getAlerts( latitude: 34.2560, longitude: -85.1650, radiusMeters: 5000, ); expect(alerts.length, equals(2)); expect(alerts[0].confidence, equals(0.0)); expect(alerts[1].confidence, isNull); }); }); group('Georgia511RoadAlertProvider Tests', () { test('fetches and parses official GDOT alerts', () async { final mockClient = MockClient((request) async { expect(request.url.path, equals('/api/v1/alerts/ga511')); final responseBody = json.encode({ 'provider_id': 'ga511_official', 'source': 'Georgia 511 / GDOT Open Data', 'alerts': [ { 'id': 'gdot_301', 'category': 'roadclosure', 'subtype': 'ROAD_CLOSED', 'latitude': 33.7500, 'longitude': -84.3900, 'road_name': 'I-20 Eastbound', 'description': 'Overnight Bridge Maintenance Closure', 'reported_at_utc': '2026-10-10T11:00:00Z', 'confidence': 0.98, 'source': 'Georgia 511' } ], 'count': 1, 'attribution': 'Georgia Department of Transportation (GDOT)' }); return http.Response(responseBody, 200, headers: {'content-type': 'application/json'}); }); final provider = Georgia511RoadAlertProvider( client: mockClient, backendBaseUrl: 'http://127.0.0.1:8000', ); final alerts = await provider.getAlerts( latitude: 33.7490, longitude: -84.3880, radiusMeters: 5000, ); expect(alerts.length, equals(1)); expect(alerts.first.id, equals('gdot_301')); expect(alerts.first.category, equals(RoadAlertCategory.roadClosure)); expect(alerts.first.source, equals('Georgia 511')); expect(alerts.first.sourceType, equals(RoadAlertSourceType.providerReported)); }); }); group('Riverpod Provider Wiring Tests for Multi-Source Alerts', () { test('zylaRoadAlertsEnabledProvider defaults to false and toggles', () async { final container = ProviderContainer(); addTearDown(container.dispose); expect(container.read(zylaRoadAlertsEnabledProvider), isFalse); await container.read(zylaRoadAlertsEnabledProvider.notifier).setEnabled(true); expect(container.read(zylaRoadAlertsEnabledProvider), isTrue); await container.read(zylaRoadAlertsEnabledProvider.notifier).setEnabled(false); expect(container.read(zylaRoadAlertsEnabledProvider), isFalse); }); test('ga511AlertsEnabledProvider defaults to false and toggles', () async { final container = ProviderContainer(); addTearDown(container.dispose); expect(container.read(ga511AlertsEnabledProvider), isFalse); await container.read(ga511AlertsEnabledProvider.notifier).setEnabled(true); expect(container.read(ga511AlertsEnabledProvider), isTrue); }); test('activeRoadAlertProviders dynamically includes enabled feeds and keeps Swarm active', () async { final container = ProviderContainer(); addTearDown(container.dispose); // Initially Swarm is enabled by default, Zyla and GA511 are disabled var activeList = container.read(activeRoadAlertProviders); expect(activeList.any((p) => p.id == 'swarm_alerts'), isTrue); expect(activeList.any((p) => p.id == 'zyla_waze_4814'), isFalse); expect(activeList.any((p) => p.id == 'ga511_official'), isFalse); // Enable Zyla await container.read(zylaRoadAlertsEnabledProvider.notifier).setEnabled(true); activeList = container.read(activeRoadAlertProviders); expect(activeList.any((p) => p.id == 'zyla_waze_4814'), isTrue); expect(activeList.any((p) => p.id == 'swarm_alerts'), isTrue); // Enable GA511 await container.read(ga511AlertsEnabledProvider.notifier).setEnabled(true); activeList = container.read(activeRoadAlertProviders); expect(activeList.any((p) => p.id == 'ga511_official'), isTrue); expect(activeList.any((p) => p.id == 'zyla_waze_4814'), isTrue); expect(activeList.any((p) => p.id == 'swarm_alerts'), isTrue); }); }); }