Files
Xpress-rider/test/maps_smoke_test.dart
2026-08-12 10:50:45 +05:30

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);
});
});
}