176 lines
6.5 KiB
Dart
176 lines
6.5 KiB
Dart
// 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<String, dynamic>;
|
|
expect(style['version'], 8, reason: 'MapLibre style spec version');
|
|
expect(style['sources'], isA<Map>());
|
|
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);
|
|
});
|
|
});
|
|
}
|