MotorSignal-App/test/road_alert_diagnostics_and_...

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