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().having((e) => e.code, 'code', 'invalidRequest')), ); expect( () => provider.resolvePlace('missing_id'), throwsA(isA().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().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() .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); }); }); }