397 lines
14 KiB
Dart
397 lines
14 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 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));
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|