MotorSignal-App/test/aws_location_route_provider...

841 lines
30 KiB
Dart

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:motorcycle_dash/services/map/map_provider_interface.dart';
import 'package:motorcycle_dash/services/routes/aws_location_route_provider.dart';
import 'package:motorcycle_dash/services/routes/route_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart';
void main() {
group('AwsLocationRouteProvider Tests', () {
test('builds privacy-safe coarse labels from AWS address value shapes', () {
final mapRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': {'Code': 'GA', 'Name': 'Georgia'},
});
final stringRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': ' Georgia ',
});
final missingRegion = AwsLocationRouteProvider.buildCoarseLocationLabel({
'Locality': 'Rome',
'Region': {'Name': ' '},
});
expect(mapRegion, 'Rome, Georgia');
expect(stringRegion, 'Rome, Georgia');
expect(missingRegion, 'Rome');
for (final label in [mapRegion, stringRegion, missingRegion]) {
expect(label, isNot(contains('{')));
expect(label, isNot(matches(RegExp(r'\d+\.\d+,\s*-?\d+\.\d+'))));
}
});
test('instance properties and custom configuration', () {
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
region: 'us-west-2',
);
expect(provider.name, equals('Amazon AWS Location'));
expect(provider.apiKey, equals('test-key'));
expect(provider.region, equals('us-west-2'));
});
test('searchPlaces returns empty list for queries shorter than 2 characters', () async {
final provider = AwsLocationRouteProvider();
final results = await provider.searchPlaces('a');
expect(results, isEmpty);
});
test('searchPlaces parses combined autocomplete and searchText results', () async {
final mockClient = MockClient((request) async {
if (request.url.path.contains('/v2/autocomplete')) {
expect(request.url.queryParameters['key'], equals('test-key'));
final body = jsonDecode(request.body);
expect(body['QueryText'], equals('Atlanta'));
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response(
jsonEncode({
'ResultItems': [
{
'PlaceId': 'place_auto_1',
'Title': 'Atlanta, GA, USA',
'Address': {'Label': 'Atlanta, Fulton County, Georgia, USA'},
}
]
}),
200,
);
} else if (request.url.path.contains('/v2/search-text')) {
final body = jsonDecode(request.body);
expect(body['Filter']?['IncludeCountries'], equals(['USA']));
return http.Response(
jsonEncode({
'ResultItems': [
{
'PlaceId': 'place_text_1',
'Title': 'Atlanta City Hall',
'Address': {'Label': '68 Mitchell St SW, Atlanta, GA 30303'},
'Position': [-84.3902, 33.7490],
}
]
}),
200,
);
}
return http.Response('Not Found', 404);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final results = await provider.searchPlaces(
'Atlanta',
nearLocation: const AppLatLng(33.7490, -84.3880),
);
expect(results.length, equals(2));
// First is text search result with coordinates
expect(results[0].providerId, equals('place_text_1'));
expect(results[0].primaryText, equals('Atlanta City Hall'));
expect(results[0].latitude, equals(33.7490));
expect(results[0].longitude, equals(-84.3902));
expect(results[0].hasCoordinates, isTrue);
// Second is autocomplete result
expect(results[1].providerId, equals('place_auto_1'));
expect(results[1].primaryText, equals('Atlanta, GA, USA'));
expect(results[1].hasCoordinates, isFalse);
});
test('resolvePlace fetches place details and returns ResolvedPlace', () async {
final mockClient = MockClient((request) async {
expect(request.url.path, contains('/v2/place/place_123'));
expect(request.url.queryParameters['key'], equals('test-key'));
return http.Response(
jsonEncode({
'PlaceId': 'place_123',
'Title': 'Blue Ridge Parkway North End',
'Address': {'Label': 'Afton, VA 22920'},
'Position': [-78.8608, 38.0315],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final place = await provider.resolvePlace('place_123');
expect(place.providerId, equals('place_123'));
expect(place.displayName, equals('Blue Ridge Parkway North End'));
expect(place.formattedAddress, equals('Afton, VA 22920'));
expect(place.latitude, equals(38.0315));
expect(place.longitude, equals(-78.8608));
});
test('resolvePlace throws on empty placeId or 404', () async {
final mockClient = MockClient((request) async {
return http.Response('Not Found', 404);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
expect(
() => provider.resolvePlace(' '),
throwsA(isA<RouteProviderException>().having((e) => e.code, 'code', 'invalidRequest')),
);
expect(
() => provider.resolvePlace('missing_id'),
throwsA(isA<RouteProviderException>().having((e) => e.code, 'code', 'placeNotFound')),
);
});
test('calculateRoute requires at least 2 stops', () async {
final provider = AwsLocationRouteProvider();
expect(
() => provider.calculateRoute([
const RouteStop(
id: '1',
order: 0,
displayName: 'Origin',
latitude: 34.0,
longitude: -84.0,
),
]),
throwsA(isA<RouteProviderException>().having((e) => e.code, 'code', 'invalidRequest')),
);
});
test('calculateRoute computes route and returns parsed geometry', () async {
final mockClient = MockClient((request) async {
expect(request.url.path, contains('/v2/routes'));
expect(request.url.queryParameters['key'], equals('test-key'));
final body = jsonDecode(request.body);
expect(body['TravelMode'], equals('Car'));
expect(body['LegGeometryFormat'], equals('Simple'));
expect(body['Origin'], equals([-84.0, 34.0]));
expect(body['Destination'], equals([-84.5, 34.5]));
return http.Response(
jsonEncode({
'Routes': [
{
'Summary': {
'Distance': 65432.1,
'Duration': 2700,
},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.25, 34.25],
[-84.5, 34.5],
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(
id: '1',
order: 0,
displayName: 'Start',
latitude: 34.0,
longitude: -84.0,
),
const RouteStop(
id: '2',
order: 1,
displayName: 'End',
latitude: 34.5,
longitude: -84.5,
),
];
final result = await provider.calculateRoute(stops);
expect(result.estimatedDistanceMeters, equals(65432.1));
expect(result.estimatedDurationSeconds, equals(2700));
expect(result.geometry.length, equals(3));
expect(result.geometry[0].latitude, equals(34.0));
expect(result.geometry[0].longitude, equals(-84.0));
expect(result.geometry[2].latitude, equals(34.5));
expect(result.geometry[2].longitude, equals(-84.5));
expect(result.isStraightLine, isFalse);
expect(result.providerName, equals('Amazon AWS Location'));
// Fallback maneuvers synthesized from geometry
expect(result.maneuvers.isNotEmpty, isTrue);
expect(result.maneuvers.first.type, equals(ManeuverType.depart));
expect(result.maneuvers.last.type, equals(ManeuverType.arrive));
});
test('calculateRoute with RoutingOptions applies avoidHighways and TravelSteps maneuvers', () async {
final mockClient = MockClient((request) async {
final body = jsonDecode(request.body);
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
expect(body['OptimizeRoutingFor'], equals('ShortestRoute'));
expect(body['LegAdditionalInputs'], contains('TravelSteps'));
return http.Response(
jsonEncode({
'Routes': [
{
'Summary': {
'Distance': 15000.0,
'Duration': 900,
},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.05, 34.05],
[-84.10, 34.10],
],
},
'VehicleLegDetails': {
'TravelSteps': [
{
'Instruction': 'Head north on Main St',
'Type': 'Depart',
'Distance': 5000.0,
'Duration': 300,
'GeometryOffset': 0,
'CurrentRoad': 'Main St',
},
{
'Instruction': 'Turn right onto Canyon Rd',
'Type': 'Turn',
'Turn': 'Right',
'Distance': 10000.0,
'Duration': 600,
'GeometryOffset': 1,
'NextRoad': 'Canyon Rd',
},
{
'Instruction': 'Arrive at destination',
'Type': 'Arrive',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
];
final result = await provider.calculateRoute(
stops,
options: const RoutingOptions(
preference: RoutingPreference.shortest,
avoidHighways: true,
),
);
expect(result.maneuvers.length, equals(3));
expect(result.maneuvers[0].instruction, equals('Head north on Main St'));
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Rd'));
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
expect(result.maneuvers[1].streetName, equals('Canyon Rd'));
expect(result.maneuvers[2].type, equals(ManeuverType.arrive));
expect(result.formattedDuration, equals('15 min'));
expect(result.curvatureMetric, isNotNull);
});
test('calculateRoute with twisty preference requests alternatives and selects highest curvature', () async {
final mockClient = MockClient((request) async {
final body = jsonDecode(request.body);
expect(body['MaxAlternatives'], equals(2));
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
return http.Response(
jsonEncode({
'Routes': [
{
// Route 1: Straight route
'Summary': {'Distance': 10000.0, 'Duration': 600},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.05, 34.05],
[-84.10, 34.10],
],
},
},
],
},
{
// Route 2: Highly twisty mountain alternative with sharp turns
'Summary': {'Distance': 12000.0, 'Duration': 900},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.02, 34.01],
[-84.01, 34.03],
[-84.05, 34.04],
[-84.03, 34.07],
[-84.08, 34.08],
[-84.10, 34.10],
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
];
final result = await provider.calculateRoute(
stops,
options: const RoutingOptions(
preference: RoutingPreference.twisty,
),
);
// Verify that Route 2 was chosen because it has more curvature
expect(result.estimatedDistanceMeters, equals(12000.0));
expect(result.estimatedDurationSeconds, equals(900));
expect(result.geometry.length, equals(7));
expect(result.curvatureMetric, isNotNull);
expect(result.curvatureMetric, greaterThan(0));
});
test('calculateRoute extracts actionable error message on AWS API failure', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'message': 'AccessDeniedException: The provided API key is unauthorized for routes.geo.us-east-1.',
}),
403,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'invalid-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'A', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'B', latitude: 34.10, longitude: -84.10),
];
expect(
() => provider.calculateRoute(stops),
throwsA(
isA<RouteProviderException>()
.having((e) => e.code, 'code', 'routeUnavailable')
.having(
(e) => e.message,
'message',
contains('AccessDeniedException: The provided API key is unauthorized'),
),
),
);
});
test('calculateRoute parses real AWS Routes V2 TurnStepDetails and ControlledAccessHighways', () async {
final mockClient = MockClient((request) async {
final body = jsonDecode(request.body);
expect(body['Avoid']['ControlledAccessHighways'], isTrue);
expect(body['TravelModeOptions']['Car']['Avoid']['Highways'], isTrue);
return http.Response(
jsonEncode({
'Routes': [
{
'Summary': {'Distance': 8500.0, 'Duration': 540},
'Legs': [
{
'Geometry': {
'LineString': [
[-84.0, 34.0],
[-84.02, 34.02],
[-84.05, 34.05],
[-84.08, 34.08],
],
},
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Distance': 100.0,
'Duration': 15,
'GeometryOffset': 0,
'CurrentRoad': 'Main St',
},
{
'Type': 'Turn',
'Distance': 2400.0,
'Duration': 180,
'GeometryOffset': 1,
'NextRoad': 'Canyon Blvd',
'TurnStepDetails': {
'SteeringDirection': 'Right',
'TurnIntensity': 'Typical',
},
},
{
'Type': 'Ramp',
'Distance': 3000.0,
'Duration': 200,
'GeometryOffset': 2,
'NextRoad': 'Mountain Pkwy',
'RampStepDetails': {
'SteeringDirection': 'Left',
},
},
{
'Type': 'Arrive',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 3,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.08, longitude: -84.08),
];
final result = await provider.calculateRoute(
stops,
options: const RoutingOptions(
avoidHighways: true,
),
);
expect(result.maneuvers.length, equals(4));
expect(result.maneuvers[0].type, equals(ManeuverType.depart));
expect(result.maneuvers[0].instruction, equals('Head out onto Main St'));
expect(result.maneuvers[1].type, equals(ManeuverType.turnRight));
expect(result.maneuvers[1].instruction, equals('Turn right onto Canyon Blvd'));
expect(result.maneuvers[2].type, equals(ManeuverType.ramp));
expect(result.maneuvers[2].instruction, equals('Take the ramp on the left onto Mountain Pkwy'));
expect(result.maneuvers[3].type, equals(ManeuverType.arrive));
expect(result.maneuvers[3].instruction, equals('Arrive at destination'));
});
test('calculateRoute canonicalizes conflicting maneuver instructions and types', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 5000.0,
'Duration': 300,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01],
[-84.02, 34.01], // Turns left
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'NextRoad': 'Main St',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Raw instruction says left, but Type is generic Continue and no turn detail
'Type': 'Continue',
'Instruction': 'Turn left onto Peachtree St',
'NextRoad': 'Peachtree St',
'Distance': 2000.0,
'Duration': 120,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at destination',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST be canonicalized to turnLeft matching its text instruction and left geometry
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
expect(result.maneuvers[1].instruction, equals('Turn left onto Peachtree St'));
});
test('calculateRoute handles bearing wraparound across North 0/360 degrees', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 3000.0,
'Duration': 180,
'Geometry': {
'LineString': [
[-84.00000, 34.00000], // Start
[-84.00176, 34.01000], // Heading ~350 degrees NNW
[-83.99824, 34.02000], // Turns to heading ~20 degrees NNE (delta = +30 deg right)
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
'Type': 'Turn',
'Instruction': 'Turn onto Mountain Pass Rd',
'NextRoad': 'Mountain Pass Rd',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at summit',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'Summit', latitude: 34.02, longitude: -83.99824),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Wrapped delta from 350 deg to 20 deg is +30 deg, which resolves to slight right turn
expect(result.maneuvers[1].type.isRight, isTrue);
expect(result.maneuvers[1].instruction.toLowerCase(), contains('right'));
});
test('calculateRoute resolves provider text vs geometry mismatch using geometry truth', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 4000.0,
'Duration': 240,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01], // Heading 0 degrees North
[-84.02, 34.01], // Heading 270 degrees West (delta = -90 deg Left turn)
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Bug in raw provider: says Turn right, but geometry is 90 degree left turn!
'Type': 'Turn',
'TurnDetail': {'SteeringDirection': 'Right'},
'Instruction': 'Turn right onto West Pkwy',
'NextRoad': 'West Pkwy',
'Distance': 2000.0,
'Duration': 120,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at destination',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 2,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'End', latitude: 34.01, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST be corrected to turnLeft because geometry bearing delta is -90 degrees
expect(result.maneuvers[1].type, equals(ManeuverType.turnLeft));
expect(result.maneuvers[1].instruction, equals('Turn left onto West Pkwy'));
expect(result.maneuvers[1].type.isLeft, isTrue);
expect(result.maneuvers[1].type.isRight, isFalse);
});
test('calculateRoute preserves continueStraight for pure road bends without generating false turn announcements', () async {
final mockClient = MockClient((request) async {
return http.Response(
jsonEncode({
'Routes': [
{
'Distance': 6000.0,
'Duration': 360,
'Geometry': {
'LineString': [
[-84.00, 34.00],
[-84.00, 34.01],
[-84.02, 34.01], // Curving road bend
[-84.02, 34.02],
],
},
'Legs': [
{
'VehicleLegDetails': {
'TravelSteps': [
{
'Type': 'Depart',
'Instruction': 'Head out onto US-129',
'NextRoad': 'US-129',
'Distance': 1000.0,
'Duration': 60,
'GeometryOffset': 0,
},
{
// Pure road bend on US-129 - Must NOT be announced as a turn!
'Type': 'Continue',
'Instruction': 'Continue onto US-129',
'NextRoad': 'US-129',
'Distance': 4000.0,
'Duration': 240,
'GeometryOffset': 1,
},
{
'Type': 'Arrive',
'Instruction': 'Arrive at Deals Gap',
'Distance': 0.0,
'Duration': 0,
'GeometryOffset': 3,
},
],
},
},
],
},
],
}),
200,
);
});
final provider = AwsLocationRouteProvider(
apiKey: 'test-key',
client: mockClient,
);
final stops = [
const RouteStop(id: '1', displayName: 'Start', latitude: 34.0, longitude: -84.0),
const RouteStop(id: '2', displayName: 'Deals Gap', latitude: 34.02, longitude: -84.02),
];
final result = await provider.calculateRoute(stops);
expect(result.maneuvers.length, equals(3));
// Step 1 MUST remain continueStraight, NOT turn left or right
expect(result.maneuvers[1].type, equals(ManeuverType.continueStraight));
expect(result.maneuvers[1].instruction, equals('Continue onto US-129'));
expect(result.maneuvers[1].type.isLeft, isFalse);
expect(result.maneuvers[1].type.isRight, isFalse);
});
});
}