272 lines
10 KiB
Dart
272 lines
10 KiB
Dart
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<RoadAlertCategory>.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);
|
|
});
|
|
});
|
|
}
|