import 'package:flutter_test/flutter_test.dart'; import 'package:geolocator/geolocator.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; import 'package:motorcycle_dash/services/alerts/road_alert_presentation_policy.dart'; import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; import 'package:motorcycle_dash/services/navigation/navigation_session_service.dart'; import 'package:motorcycle_dash/services/routes/route_provider.dart'; void main() { group('RoadAlertPresentationPolicy Unit Tests', () { final now = DateTime(2026, 10, 13, 12, 0, 0); Position createPosition({ double lat = 34.0500, double lng = -84.4000, double accuracy = 10.0, double speed = 15.0, // m/s (~33.5 MPH) double heading = 0.0, // North DateTime? timestamp, }) { return Position( latitude: lat, longitude: lng, timestamp: timestamp ?? now, accuracy: accuracy, altitude: 300.0, altitudeAccuracy: 5.0, heading: heading, headingAccuracy: 5.0, speed: speed, speedAccuracy: 0.5, ); } RoadAlert createAlert({ String id = 'alert_1', RoadAlertCategory category = RoadAlertCategory.police, double lat = 34.0600, double lng = -84.4000, double? distance, DateTime? expiresAt, }) { return RoadAlert( id: id, providerId: 'waze_zyla', category: category, latitude: lat, longitude: lng, description: 'Speed Trap', roadName: 'Main Highway', reportedAt: now.subtract(const Duration(minutes: 5)), expiresAt: expiresAt ?? now.add(const Duration(minutes: 25)), lastUpdated: now.subtract(const Duration(minutes: 1)), distanceFromUser: distance, source: 'Waze', sourceType: RoadAlertSourceType.providerReported, ); } test('isLocationFreshAndAccurate validates freshness and accuracy thresholds', () { final fresh = createPosition(timestamp: now.subtract(const Duration(seconds: 4)), accuracy: 15.0); expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: fresh, now: now), isTrue); final stale = createPosition(timestamp: now.subtract(const Duration(seconds: 15)), accuracy: 15.0); expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: stale, now: now), isFalse); final inaccurate = createPosition(timestamp: now, accuracy: 80.0); expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: inaccurate, now: now), isFalse); expect(RoadAlertPresentationPolicy.isLocationFreshAndAccurate(position: null, now: now), isFalse); }); test('calculateBearingDelta computes angular delta correctly', () { final deltaNorth = RoadAlertPresentationPolicy.calculateBearingDelta( riderHeading: 0.0, userLat: 34.0500, userLng: -84.4000, alertLat: 34.0600, alertLng: -84.4000, ); expect(deltaNorth, lessThan(1.0)); final deltaOpposite = RoadAlertPresentationPolicy.calculateBearingDelta( riderHeading: 180.0, userLat: 34.0500, userLng: -84.4000, alertLat: 34.0600, alertLng: -84.4000, ); expect((deltaOpposite - 180.0).abs(), lessThan(1.0)); }); test('evaluateDirectionAndRoute handles stationary vs moving rider without route', () { final alertNorth = createAlert(lat: 34.0600, lng: -84.4000); // Stationary (speed = 0.5 m/s < 2.0 m/s): cannot claim Ahead final stationaryPos = createPosition(speed: 0.5, heading: 0.0); final stationaryResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: stationaryPos, alert: alertNorth, navSession: null, ); expect(stationaryResult.isAhead, isFalse); // Moving North (heading = 0.0, speed = 15 m/s): alert North is Ahead final movingNorthPos = createPosition(speed: 15.0, heading: 0.0); final movingNorthResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: movingNorthPos, alert: alertNorth, navSession: null, ); expect(movingNorthResult.isAhead, isTrue); // Moving South (heading = 180.0): alert North is behind rider final movingSouthPos = createPosition(speed: 15.0, heading: 180.0); final movingSouthResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: movingSouthPos, alert: alertNorth, navSession: null, ); expect(movingSouthResult.isAhead, isFalse); }); test('evaluateDirectionAndRoute respects active route corridor', () { final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); final routeGeometry = [ AppLatLng(34.0500, -84.4000), AppLatLng(34.0600, -84.4000), AppLatLng(34.0700, -84.4000), AppLatLng(34.0800, -84.4000), ]; final navSession = NavigationSessionState( status: NavigationStatus.navigating, calculationResult: RouteCalculationResult( straightLineDistanceMeters: 3330.0, geometry: routeGeometry, isStraightLine: false, providerName: 'test', ), ); // Alert 1: Directly on route corridor ahead at 34.0650 final onRouteAlert = createAlert(id: 'on_route', lat: 34.0650, lng: -84.4000); final onRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: userPos, alert: onRouteAlert, navSession: navSession, ); expect(onRouteResult.isAhead, isTrue); expect(onRouteResult.onRoute, isTrue); // Alert 2: 1 km off the route corridor to the East (-84.3890) final offRouteAlert = createAlert(id: 'off_route', lat: 34.0650, lng: -84.3890); final offRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: userPos, alert: offRouteAlert, navSession: navSession, ); expect(offRouteResult.isAhead, isFalse); expect(offRouteResult.onRoute, isFalse); // Alert 3: Along the route corridor line but behind the user (South at 34.0400) final behindRouteAlert = createAlert(id: 'behind_route', lat: 34.0400, lng: -84.4000); final behindRouteResult = RoadAlertPresentationPolicy.evaluateDirectionAndRoute( userPosition: userPos, alert: behindRouteAlert, navSession: navSession, ); expect(behindRouteResult.isAhead, isFalse); }); test('evaluateForDashboard enforces decoupled tactical (25 km) vs dashboard (2.0 mi) thresholds', () { final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); final enabledCategories = Set.from(RoadAlertCategory.values); // Alert A: 1200 meters (~0.75 mi) North ahead -> Tactical & Dashboard eligible final closeAlert = createAlert(id: 'close', lat: 34.0608, lng: -84.4000); final closeEval = RoadAlertPresentationPolicy.evaluateForDashboard( alert: closeAlert, userPosition: userPos, navSession: null, dismissedIds: {}, enabledCategories: enabledCategories, now: now, ); expect(closeEval.isTacticalMapEligible, isTrue); expect(closeEval.isDashboardEligible, isTrue); expect(closeEval.dashboardRejectionReason, AlertRejectionReason.none); // Alert B: 6000 meters (~3.7 mi) North ahead -> Tactical eligible (<= 25 km), Dashboard REJECTED (> 2.0 mi) final midAlert = createAlert(id: 'mid', lat: 34.1040, lng: -84.4000); final midEval = RoadAlertPresentationPolicy.evaluateForDashboard( alert: midAlert, userPosition: userPos, navSession: null, dismissedIds: {}, enabledCategories: enabledCategories, now: now, ); expect(midEval.isTacticalMapEligible, isTrue); expect(midEval.isDashboardEligible, isFalse); expect(midEval.dashboardRejectionReason, AlertRejectionReason.distanceExceedsDashboardThreshold); // Alert C: 30,000 meters (~18.6 mi) -> Both Tactical and Dashboard REJECTED (> 25 km) final farAlert = createAlert(id: 'far', lat: 34.3200, lng: -84.4000); final farEval = RoadAlertPresentationPolicy.evaluateForDashboard( alert: farAlert, userPosition: userPos, navSession: null, dismissedIds: {}, enabledCategories: enabledCategories, now: now, ); expect(farEval.isTacticalMapEligible, isFalse); expect(farEval.isDashboardEligible, isFalse); expect(farEval.dashboardRejectionReason, AlertRejectionReason.outOfTacticalRange); }); test('evaluateForDashboard rejects stale GPS fixes with staleOrMissingGps', () { final stalePos = createPosition( lat: 34.0500, lng: -84.4000, timestamp: now.subtract(const Duration(seconds: 20)), ); final alert = createAlert(lat: 34.0600, lng: -84.4000); final eval = RoadAlertPresentationPolicy.evaluateForDashboard( alert: alert, userPosition: stalePos, navSession: null, dismissedIds: {}, enabledCategories: Set.from(RoadAlertCategory.values), now: now, ); expect(eval.isDashboardEligible, isFalse); expect(eval.dashboardRejectionReason, AlertRejectionReason.staleOrMissingGps); }); test('evaluateForAudio identifies candidate within 400m and ahead', () { final userPos = createPosition(lat: 34.0500, lng: -84.4000, heading: 0.0, speed: 15.0); // Alert 250 meters ahead final nearAlert = createAlert(id: 'near', lat: 34.0522, lng: -84.4000); final nearAudio = RoadAlertPresentationPolicy.evaluateForAudio( alert: nearAlert, userPosition: userPos, navSession: null, dismissedIds: {}, enabledCategories: Set.from(RoadAlertCategory.values), audioRadiusMeters: 400.0, now: now, ); expect(nearAudio.isAudioEligible, isTrue); // Alert 1200 meters ahead (dashboard eligible, but outside 400m audio window) final farAlert = createAlert(id: 'far_audio', lat: 34.0608, lng: -84.4000); final farAudio = RoadAlertPresentationPolicy.evaluateForAudio( alert: farAlert, userPosition: userPos, navSession: null, dismissedIds: {}, enabledCategories: Set.from(RoadAlertCategory.values), audioRadiusMeters: 400.0, now: now, ); expect(farAudio.isAudioEligible, isFalse); }); }); }