// Smoke test for the map/routing stack. // // This deliberately hits the LIVE endpoints the app uses, because the point is // to verify the integration end to end — not to test mocks. It therefore needs // network access and is not suitable for offline CI. // // flutter test test/maps_smoke_test.dart import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:nearle/services/maps/map_config.dart'; import 'package:nearle/services/maps/map_marker_icons.dart'; import 'package:nearle/services/maps/routing_service.dart'; import 'package:maplibre_gl/maplibre_gl.dart' show LatLng; void main() { // The binding is needed to rasterise marker icons, but it also installs an // HttpOverrides that stubs every request with a 400. Clear it so the LIVE // groups below actually reach the network. TestWidgetsFlutterBinding.ensureInitialized(); HttpOverrides.global = null; // Two real points in Coimbatore, where Nearle operates. const origin = LatLng(11.0168, 76.9558); const destination = LatLng(10.9984, 76.9776); group('polyline decoder (offline)', () { test('decodes a known Google-encoded polyline', () { // Canonical example from Google's encoded-polyline spec: // (38.5,-120.2) (40.7,-120.95) (43.252,-126.453) final points = RoutingService.decodePolyline('_p~iF~ps|U_ulLnnqC_mqNvxq`@'); expect(points.length, 3); expect(points[0].latitude, closeTo(38.5, 0.00001)); expect(points[0].longitude, closeTo(-120.2, 0.00001)); expect(points[2].latitude, closeTo(43.252, 0.00001)); expect(points[2].longitude, closeTo(-126.453, 0.00001)); }); test('returns empty for garbage rather than throwing', () { expect(RoutingService.decodePolyline(''), isEmpty); expect(() => RoutingService.decodePolyline('!!!'), returnsNormally); }); }); group('marker icons (offline)', () { test('renders PNG bytes with a stable cache key', () async { final red = await MapMarkerIcons.pin(Colors.red); final redAgain = await MapMarkerIcons.pin(Colors.red); final green = await MapMarkerIcons.pin(Colors.green); final dot = await MapMarkerIcons.dot(Colors.blueAccent); final step = await MapMarkerIcons.numberedCircle( 3, primaryColor: Colors.deepPurple, ); // PNG magic number. for (final icon in [red, green, dot, step]) { expect(icon.bytes.length, greaterThan(100)); expect(icon.bytes.sublist(0, 4), [0x89, 0x50, 0x4E, 0x47]); } expect(red.key, redAgain.key, reason: 'same colour must reuse the key'); expect(identical(red.bytes, redAgain.bytes), isTrue, reason: 'second call must come from cache'); expect(red.key, isNot(green.key)); expect(step.key, contains('3')); }); }); group('LIVE tile style', () { test('style document is fetchable and MapLibre-shaped', () async { final response = await http .get(Uri.parse(MapConfig.styleUrl)) .timeout(const Duration(seconds: 30)); expect(response.statusCode, 200, reason: MapConfig.styleUrl); final style = json.decode(response.body) as Map; expect(style['version'], 8, reason: 'MapLibre style spec version'); expect(style['sources'], isA()); expect((style['layers'] as List), isNotEmpty); // No credential should be needed anywhere in the document. expect( response.body.contains('api_key='), isFalse, reason: 'tile style must not require a key', ); }, timeout: const Timeout(Duration(seconds: 60))); }); group('LIVE routing', () { setUp(RoutingService.clearCache); test('returns a plausible road route', () async { final route = await RoutingService.getRoute( origin: origin, destination: destination, ); expect(route, isNotNull, reason: 'no route from ${MapConfig.osrmBaseUrl}'); route!; // ~2.5 km straight line; the road route must be longer but not absurd. expect(route.distanceMeters, greaterThan(2000)); expect(route.distanceMeters, lessThan(15000)); expect(route.durationSeconds, greaterThan(0)); // Geometry must actually be decoded, not a two-point fallback. expect(route.points.length, greaterThan(10)); // Route must start and end near the requested points, which also proves // the lon,lat ordering in the request is right — swapped coordinates // would land in the Indian Ocean. expect(route.points.first.latitude, closeTo(origin.latitude, 0.02)); expect(route.points.first.longitude, closeTo(origin.longitude, 0.02)); expect(route.points.last.latitude, closeTo(destination.latitude, 0.02)); expect(route.points.last.longitude, closeTo(destination.longitude, 0.02)); }, timeout: const Timeout(Duration(seconds: 60))); test('multi-stop route is one call and visits every waypoint', () async { const waypoint = LatLng(11.0075, 76.9660); final route = await RoutingService.getRoute( origin: origin, destination: destination, waypoints: const [waypoint], ); expect(route, isNotNull); route!; expect(route.points.length, greaterThan(10)); // The path should pass near the waypoint. final passesNear = route.points.any( (p) => (p.latitude - waypoint.latitude).abs() < 0.01 && (p.longitude - waypoint.longitude).abs() < 0.01, ); expect(passesNear, isTrue, reason: 'route ignored the waypoint'); }, timeout: const Timeout(Duration(seconds: 60))); test('second identical request is served from cache', () async { final first = await RoutingService.getRoute( origin: origin, destination: destination, ); expect(first, isNotNull); final watch = Stopwatch()..start(); final second = await RoutingService.getRoute( origin: origin, destination: destination, ); watch.stop(); expect(identical(first, second), isTrue, reason: 'cache must return the same instance'); expect(watch.elapsedMilliseconds, lessThan(50), reason: 'cache hit should not touch the network'); }, timeout: const Timeout(Duration(seconds: 60))); test('invalid coordinates fail closed instead of throwing', () async { final route = await RoutingService.getRoute( origin: const LatLng(0, 0), destination: destination, ); expect(route, isNull); }); }); }