401 lines
14 KiB
Dart
401 lines
14 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/services.dart';
|
|
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/models/field_test_event.dart';
|
|
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
|
|
import 'package:motorcycle_dash/services/location_service.dart';
|
|
import 'package:motorcycle_dash/services/map/map_layer_settings.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/road_alert_service.dart';
|
|
import 'package:motorcycle_dash/services/alerts/zyla_road_alert_provider.dart';
|
|
|
|
class _NoOpFieldTestLogger extends Fake implements FieldTestLogger {
|
|
@override
|
|
void log({
|
|
required FieldTestEventType eventType,
|
|
required EventSeverity severity,
|
|
required String message,
|
|
int? rideId,
|
|
DateTime? timestamp,
|
|
DateTime? telemetryTimestamp,
|
|
int? gpsAgeMs,
|
|
double? speed,
|
|
double? latitude,
|
|
double? longitude,
|
|
double? gpsAccuracy,
|
|
double? leanAngle,
|
|
double? longitudinalG,
|
|
double? lateralG,
|
|
double? overallConfidence,
|
|
Map<String, dynamic>? metadata,
|
|
}) {}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const flutterTtsChannel = MethodChannel('flutter_tts');
|
|
const sensorsChannel = MethodChannel('dev.fluttercommunity.plus/sensors/method');
|
|
|
|
setUpAll(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(flutterTtsChannel, (call) async {
|
|
return 1;
|
|
});
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(sensorsChannel, (call) async {
|
|
return null;
|
|
});
|
|
});
|
|
|
|
tearDownAll(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(flutterTtsChannel, null);
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(sensorsChannel, null);
|
|
});
|
|
|
|
Map<String, dynamic> createRomeSampleResponse() {
|
|
final cur = DateTime.now();
|
|
return {
|
|
'success': true,
|
|
'count': 3,
|
|
'alerts': [
|
|
{
|
|
'id': 'zyla_rome_pol_01',
|
|
'category': 'police',
|
|
'subtype': 'police_visible',
|
|
'latitude': 34.2750,
|
|
'longitude': -85.1700,
|
|
'road_name': 'Martha Berry Hwy',
|
|
'description': 'Stationary radar trap Northbound on shoulder',
|
|
'reported_at_utc': cur.subtract(const Duration(minutes: 5)).toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(minutes: 40)).toIso8601String(),
|
|
'confidence': 0.8,
|
|
'source': 'Zyla Waze Data #4814',
|
|
},
|
|
{
|
|
'id': 'zyla_rome_haz_01',
|
|
'category': 'hazard',
|
|
'subtype': 'hazard_on_road',
|
|
'latitude': 34.2500,
|
|
'longitude': -85.2000,
|
|
'road_name': 'GA-20 / Alabama Hwy',
|
|
'description': 'Tree branch in right lane around bend',
|
|
'reported_at_utc': cur.subtract(const Duration(minutes: 8)).toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(minutes: 30)).toIso8601String(),
|
|
'confidence': 0.9,
|
|
'source': 'Zyla Waze Data #4814',
|
|
},
|
|
{
|
|
'id': 'zyla_rome_acc_01',
|
|
'category': 'accident',
|
|
'subtype': 'accident_minor',
|
|
'latitude': 34.2580,
|
|
'longitude': -85.1650,
|
|
'road_name': 'Turner McCall Blvd',
|
|
'description': 'Minor fender bender blocking left turn lane',
|
|
'reported_at_utc': cur.subtract(const Duration(minutes: 12)).toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(minutes: 45)).toIso8601String(),
|
|
'confidence': 0.85,
|
|
'source': 'Zyla Waze Data #4814',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
group('Zyla End-to-End Alert Ingestion and Diagnostics Tests', () {
|
|
test('1. Parses realistic recorded Zyla responses and populates metadata correctly', () async {
|
|
final mockClient = MockClient((request) async {
|
|
expect(request.url.path, equals('/api/v1/alerts/zyla'));
|
|
expect(request.url.queryParameters['min_lat'], isNotNull);
|
|
expect(request.url.queryParameters['max_lat'], isNotNull);
|
|
expect(request.url.queryParameters['fixture_override'], isNull); // not appended when null
|
|
return http.Response(json.encode(createRomeSampleResponse()), 200);
|
|
});
|
|
|
|
final provider = ZylaRoadAlertProvider(
|
|
client: mockClient,
|
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
|
getAuthToken: () async => 'mock_token',
|
|
);
|
|
|
|
final alerts = await provider.getAlerts(
|
|
latitude: 34.2560,
|
|
longitude: -85.1650,
|
|
radiusMeters: 25000.0,
|
|
);
|
|
|
|
expect(alerts.length, equals(3));
|
|
expect(provider.lastRawCount, equals(3));
|
|
expect(provider.lastStatus, equals(RoadAlertProviderStatus.connected));
|
|
expect(provider.lastWasLive, isTrue);
|
|
expect(provider.lastError, isNull);
|
|
|
|
final policeAlert = alerts.firstWhere((a) => a.id == 'zyla_rome_pol_01');
|
|
expect(policeAlert.category, equals(RoadAlertCategory.police));
|
|
expect(policeAlert.description, contains('Stationary radar trap'));
|
|
expect(policeAlert.latitude, equals(34.2750));
|
|
expect(policeAlert.longitude, equals(-85.1700));
|
|
expect(policeAlert.roadName, equals('Martha Berry Hwy'));
|
|
});
|
|
|
|
test('2. Decoupled Tactical Horizon: displays alerts within 25 km on map without triggering premature audio warnings', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(json.encode(createRomeSampleResponse()), 200);
|
|
});
|
|
|
|
final customZylaProvider = ZylaRoadAlertProvider(
|
|
client: mockClient,
|
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
|
getAuthToken: () async => 'mock_token',
|
|
);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()),
|
|
zylaProviderInstance.overrideWithValue(customZylaProvider),
|
|
zylaRoadAlertsEnabledProvider.overrideWith((ref) {
|
|
final notifier = ZylaRoadAlertsEnabledNotifier();
|
|
notifier.state = true;
|
|
return notifier;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final service = container.read(roadAlertServiceProvider);
|
|
|
|
// Rider located in Downtown Rome (~2.2 km away from police alert, ~3.3 km from hazard alert)
|
|
await service.refreshAlerts(
|
|
overrideLat: 34.2560,
|
|
overrideLng: -85.1650,
|
|
overrideRadius: 25000.0,
|
|
force: true,
|
|
);
|
|
|
|
expect(service.cachedAlerts.length, equals(3));
|
|
|
|
final diag = container.read(roadAlertDiagnosticsProvider);
|
|
expect(diag.displayedOnMapCount, equals(3));
|
|
expect(diag.rawReportsCount, equals(3));
|
|
expect(diag.lastRequestedArea, contains('25.0 km radius'));
|
|
|
|
// Police alert is 2.2 km away -> MUST NOT be in immediate audio candidate list (<400m)
|
|
expect(diag.audioCandidateCount, equals(1)); // only accident at ~220m is an audio candidate
|
|
});
|
|
|
|
test('3. Stage-by-Stage Rejection Funnel: tracks stale, disabled category, out of range, and dismissed counts', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final cur = DateTime.now();
|
|
|
|
final mixedAlertsResponse = {
|
|
'success': true,
|
|
'count': 4,
|
|
'alerts': [
|
|
{
|
|
'id': 'alert_valid_police',
|
|
'category': 'police',
|
|
'latitude': 34.2562,
|
|
'longitude': -85.1652,
|
|
'description': 'Active radar',
|
|
'reported_at_utc': cur.subtract(const Duration(minutes: 5)).toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(minutes: 30)).toIso8601String(),
|
|
'source': 'Zyla',
|
|
},
|
|
{
|
|
'id': 'alert_expired_hazard',
|
|
'category': 'hazard',
|
|
'latitude': 34.2565,
|
|
'longitude': -85.1655,
|
|
'description': 'Old pothole',
|
|
'reported_at_utc': cur.subtract(const Duration(hours: 3)).toIso8601String(),
|
|
'expires_at_utc': cur.subtract(const Duration(minutes: 10)).toIso8601String(), // EXPIRED
|
|
'source': 'Zyla',
|
|
},
|
|
{
|
|
'id': 'alert_far_away',
|
|
'category': 'hazard',
|
|
'latitude': 35.5000, // ~140 km away (out of 25 km range)
|
|
'longitude': -85.1650,
|
|
'description': 'Mountain pass rockslide',
|
|
'reported_at_utc': cur.toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(hours: 1)).toIso8601String(),
|
|
'source': 'Zyla',
|
|
},
|
|
{
|
|
'id': 'alert_weather',
|
|
'category': 'weatherHazard',
|
|
'latitude': 34.2570,
|
|
'longitude': -85.1660,
|
|
'description': 'Flash flood',
|
|
'reported_at_utc': cur.toIso8601String(),
|
|
'expires_at_utc': cur.add(const Duration(hours: 1)).toIso8601String(),
|
|
'source': 'Zyla',
|
|
},
|
|
],
|
|
};
|
|
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(json.encode(mixedAlertsResponse), 200);
|
|
});
|
|
|
|
final customZylaProvider = ZylaRoadAlertProvider(
|
|
client: mockClient,
|
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
|
getAuthToken: () async => 'mock_token',
|
|
);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()),
|
|
zylaProviderInstance.overrideWithValue(customZylaProvider),
|
|
zylaRoadAlertsEnabledProvider.overrideWith((ref) {
|
|
final notifier = ZylaRoadAlertsEnabledNotifier();
|
|
notifier.state = true;
|
|
return notifier;
|
|
}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
// Disable weatherHazard category to test category rejection
|
|
container.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.weatherHazard, false);
|
|
|
|
final service = container.read(roadAlertServiceProvider);
|
|
|
|
// Dismiss alert_valid_police manually
|
|
service.dismissAlert('alert_valid_police');
|
|
|
|
await service.refreshAlerts(
|
|
overrideLat: 34.2560,
|
|
overrideLng: -85.1650,
|
|
overrideRadius: 25000.0,
|
|
force: true,
|
|
);
|
|
|
|
final diag = container.read(roadAlertDiagnosticsProvider);
|
|
|
|
expect(diag.rawReportsCount, equals(4));
|
|
expect(diag.rejectedReasons['stale_or_expired'], equals(1));
|
|
expect(diag.rejectedReasons['out_of_tactical_range'], equals(1));
|
|
expect(diag.rejectedReasons['category_disabled'], equals(1));
|
|
expect(diag.rejectedReasons['dismissed'], equals(1));
|
|
expect(diag.totalRejectedCount, equals(4));
|
|
expect(diag.displayedOnMapCount, equals(0));
|
|
});
|
|
|
|
test('4. MapScreen Police Traps Decoupling: hiding police traps preserves other hazards and closures', () {
|
|
final now = DateTime.now();
|
|
final policeAlert = RoadAlert(
|
|
id: 'p1',
|
|
providerId: 'zyla',
|
|
category: RoadAlertCategory.police,
|
|
latitude: 34.2560,
|
|
longitude: -85.1650,
|
|
description: 'Speed trap',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'Waze',
|
|
);
|
|
|
|
final hazardAlert = RoadAlert(
|
|
id: 'h1',
|
|
providerId: 'zyla',
|
|
category: RoadAlertCategory.hazard,
|
|
latitude: 34.2570,
|
|
longitude: -85.1660,
|
|
description: 'Gravel on corner',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'Waze',
|
|
);
|
|
|
|
final closureAlert = RoadAlert(
|
|
id: 'c1',
|
|
providerId: 'zyla',
|
|
category: RoadAlertCategory.roadClosure,
|
|
latitude: 34.2580,
|
|
longitude: -85.1670,
|
|
description: 'Bridge closed',
|
|
reportedAt: now,
|
|
lastUpdated: now,
|
|
source: 'Waze',
|
|
);
|
|
|
|
final allAlerts = [policeAlert, hazardAlert, closureAlert];
|
|
|
|
// Case A: showPoliceTraps is TRUE (default)
|
|
const settingsWithPolice = MapLayerSettings(showPoliceTraps: true);
|
|
final alertsA = allAlerts.where((alert) {
|
|
if (!settingsWithPolice.showPoliceTraps &&
|
|
(alert.category == RoadAlertCategory.police ||
|
|
alert.category == RoadAlertCategory.speedCamera)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
expect(alertsA.length, equals(3));
|
|
|
|
// Case B: showPoliceTraps is FALSE (user toggled off police traps)
|
|
const settingsNoPolice = MapLayerSettings(showPoliceTraps: false);
|
|
final alertsB = allAlerts.where((alert) {
|
|
if (!settingsNoPolice.showPoliceTraps &&
|
|
(alert.category == RoadAlertCategory.police ||
|
|
alert.category == RoadAlertCategory.speedCamera)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
|
|
expect(alertsB.length, equals(2));
|
|
expect(alertsB.any((a) => a.category == RoadAlertCategory.police), isFalse);
|
|
expect(alertsB.any((a) => a.category == RoadAlertCategory.hazard), isTrue);
|
|
expect(alertsB.any((a) => a.category == RoadAlertCategory.roadClosure), isTrue);
|
|
});
|
|
|
|
test('5. Quota & Rate Limit Handling: 429 sets rateLimited status with clear explanation and zero secret leakage', () async {
|
|
final mockClient = MockClient((request) async {
|
|
return http.Response(
|
|
json.encode({'detail': 'Monthly quota limit (500 requests) exceeded.'}),
|
|
429,
|
|
headers: {'content-type': 'application/json'},
|
|
);
|
|
});
|
|
|
|
final provider = ZylaRoadAlertProvider(
|
|
client: mockClient,
|
|
backendBaseUrl: 'http://127.0.0.1:8000',
|
|
getAuthToken: () async => 'mock_token',
|
|
);
|
|
|
|
final alerts = await provider.getAlerts(
|
|
latitude: 34.2560,
|
|
longitude: -85.1650,
|
|
radiusMeters: 25000.0,
|
|
);
|
|
|
|
expect(alerts, isEmpty);
|
|
expect(provider.lastStatus, equals(RoadAlertProviderStatus.rateLimited));
|
|
expect(provider.lastError, contains('Monthly API budget (500 reqs)'));
|
|
expect(provider.lastError, isNot(contains('key')));
|
|
expect(provider.lastError, isNot(contains('secret')));
|
|
});
|
|
|
|
test('6. Location Bootstrap: LocationService exposes lastValidPosition cleanly', () {
|
|
final service = LocationService();
|
|
addTearDown(service.dispose);
|
|
|
|
expect(service.lastValidPosition, isNull);
|
|
});
|
|
});
|
|
}
|