MotorSignal-App/test/multi_source_road_alerts_te...

564 lines
20 KiB
Dart

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('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);
});
});
}