Files
rippr/test/ride_map_test.dart
uhryniuk f30f0063d0 FB-01: fix ambient GPS's first fix not correcting the initial zoom-2
Emulator verification caught a real bug the widget tests missed: the
shell background map builds before any location fix has resolved, so
ambientPosition starts null and the initial camera locks at zoom 2.
The first fix to arrive only re-centered (preserving that stale zoom
forever) instead of also jumping to ambientZoom.
2026-08-24 16:44:09 -05:00

371 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart' as ll;
import 'package:rippr/src/domain/models.dart';
import 'package:rippr/src/ui/components/ride_map.dart';
import 'package:rippr/src/ui/components/skeleton_map_layer.dart';
import 'package:rippr/src/ui/theme.dart';
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
/// geometry, which is where every real map bug in this project has lived, is fully
/// exercisable.
void main() {
TrackPoint p(int segmentId, int i, {double speed = 40, double lat = 51.0}) =>
TrackPoint(
id: i + 1,
tripId: 1,
segmentId: segmentId,
timestamp: 1000 + i * 1000,
latitude: lat + i * 0.0005,
longitude: -114.0,
speedKmh: speed,
altitudeM: 1000,
);
Future<List<Polyline>> render(
WidgetTester tester, {
required List<TrackPoint> points,
required List<Segment> segments,
}) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(body: RideMap(points: points, segments: segments)),
));
await tester.pump();
final layers = tester
.widgetList<PolylineLayer>(find.byType(PolylineLayer))
.toList();
return layers.isEmpty ? const [] : layers.first.polylines.cast<Polyline>();
}
testWidgets('an empty ride says so instead of drawing a blank map',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: []),
),
));
await tester.pump();
expect(find.text('No path recorded'), findsOneWidget);
expect(find.byType(FlutterMap), findsNothing);
});
testWidgets('a pause leaves a gap: segments are never joined', (tester) async {
// Two segments a degree apart. If they were joined, one polyline would span ~111 km.
final points = [
for (var i = 0; i < 6; i++) p(1, i, lat: 51.0),
for (var i = 0; i < 6; i++) p(2, i, lat: 52.0),
];
const segments = [
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
];
final polylines = await render(tester, points: points, segments: segments);
expect(polylines, isNotEmpty);
for (final line in polylines) {
final lats = line.points.map((c) => c.latitude);
// No single polyline may straddle the gap between the two rides.
expect(lats.every((l) => l < 51.5) || lats.every((l) => l > 51.5), isTrue,
reason: 'a polyline spanned the pause');
}
});
testWidgets('decimation reduces rendered vertices but keeps the shape',
(tester) async {
// A dense straight run: Douglas-Peucker should collapse it hard.
final points = [for (var i = 0; i < 400; i++) p(1, i)];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
final polylines = await render(tester, points: points, segments: segments);
final rendered =
polylines.fold<int>(0, (a, line) => a + line.points.length);
expect(rendered, lessThan(points.length),
reason: 'render-only decimation should reduce vertex count');
expect(rendered, greaterThan(1));
});
testWidgets('a stationary ride still renders without zooming to infinity',
(tester) async {
// Every point at one spot — degenerate bounds. Fitting these would zoom past the
// tile server's maximum and render an empty grid, the v2.0 short-ride bug.
final points = [
for (var i = 0; i < 5; i++)
TrackPoint(
id: i + 1,
tripId: 1,
segmentId: 1,
timestamp: 1000 + i * 1000,
latitude: 51.0,
longitude: -114.0,
speedKmh: 0,
altitudeM: 1000,
),
];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(body: RideMap(points: points, segments: segments)),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialZoom, shortRideZoom);
expect(map.options.maxZoom, maxTileZoom,
reason: 'exceeding OSM max tile zoom renders an empty grid');
});
group('skeleton mode (UI-02)', () {
testWidgets('shows the skeleton and no TileLayer when skeletonMode is true',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false, skeletonMode: true),
),
));
await tester.pump();
expect(find.byType(SkeletonMapLayer), findsOneWidget);
expect(find.byType(TileLayer), findsNothing);
});
testWidgets('shows the TileLayer and no skeleton when skeletonMode is false',
(tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false),
),
));
await tester.pump();
expect(find.byType(TileLayer), findsOneWidget);
expect(find.byType(SkeletonMapLayer), findsNothing);
});
testWidgets('the recorded path still renders on top of the skeleton',
(tester) async {
final points = [
for (var i = 0; i < 4; i++)
TrackPoint(
id: i + 1,
tripId: 1,
segmentId: 1,
timestamp: 1000 + i * 1000,
latitude: 51.0 + i * 0.0005,
longitude: -114.0,
speedKmh: 40,
altitudeM: 1000,
),
];
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(points: points, segments: segments, skeletonMode: true),
),
));
await tester.pump();
expect(find.byType(SkeletonMapLayer), findsOneWidget);
expect(find.byType(TileLayer), findsNothing);
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
expect(layer.polylines, isNotEmpty,
reason: 'the path comes from local data, not tiles -- it must not vanish '
'just because the tile fetch is failing');
});
});
group('ambient position (FB-01)', () {
testWidgets('with no recorded points, an ambient position centers the map at '
'street level', (tester) async {
const fix = ll.LatLng(51.05, -114.05);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
ambientPosition: fix,
),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialCenter, fix);
expect(map.options.initialZoom, ambientZoom);
});
testWidgets('with neither recorded points nor an ambient fix, the map still '
'falls back to (0, 0) at zoom 2 (no regression, no crash)', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(points: [], segments: [], showEmptyLabel: false),
),
));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialCenter, const ll.LatLng(0, 0));
expect(map.options.initialZoom, 2);
});
testWidgets('while following, a new ambient fix re-centers the map, exactly '
'like the recorded-path chase', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(51.01, -114.01);
Widget build(ll.LatLng ambient) => MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(
points: const [],
segments: const [],
showEmptyLabel: false,
follow: true,
ambientPosition: ambient,
),
),
);
await tester.pumpWidget(build(fix1));
await tester.pump();
await tester.pumpWidget(build(fix2));
await tester.pump();
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix2.latitude, 1e-9));
expect(center.longitude, closeTo(fix2.longitude, 1e-9));
});
testWidgets('the first ambient fix to arrive after construction corrects the '
'zoom, not just the center -- a plain re-center would otherwise leave the '
'map at the initial zoom-2 forever', (tester) async {
// Reproduces a real device bug found in emulator verification: the shell
// background map builds before any location permission/fix has resolved, so
// `ambientPosition` starts null and the initial camera locks at zoom 2 (the
// `bounds == null` fallback). The first fix to actually arrive must jump to
// `ambientZoom`, not just re-center at whatever zoom the map already happens
// to be sitting at.
const fix = ll.LatLng(51.0, -114.0);
Widget build(ll.LatLng? ambient) => MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(
points: const [],
segments: const [],
showEmptyLabel: false,
follow: true,
ambientPosition: ambient,
),
),
);
await tester.pumpWidget(build(null));
await tester.pump();
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.mapController!.camera.zoom, 2);
await tester.pumpWidget(build(fix));
await tester.pump();
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.mapController!.camera.zoom, ambientZoom);
expect(map.mapController!.camera.center.latitude, closeTo(fix.latitude, 1e-9));
expect(map.mapController!.camera.center.longitude, closeTo(fix.longitude, 1e-9));
});
testWidgets('a manual pan cancels ambient following the same way it cancels '
'recording-follow', (tester) async {
const fix1 = ll.LatLng(51.0, -114.0);
const fix2 = ll.LatLng(51.01, -114.01);
Widget build(ll.LatLng ambient) => MaterialApp(
theme: ripprTheme(),
home: Scaffold(
body: RideMap(
points: const [],
segments: const [],
showEmptyLabel: false,
follow: true,
ambientPosition: ambient,
),
),
);
await tester.pumpWidget(build(fix1));
await tester.pump();
// Simulate a real user gesture the same way flutter_map itself would report
// one to `onPositionChanged` -- calling the callback directly with
// `hasGesture: true` exercises the exact guard in `_RideMapState` without
// needing a real pointer gesture to get past `InteractionOptions`.
var map = tester.widget<FlutterMap>(find.byType(FlutterMap));
map.options.onPositionChanged!(map.mapController!.camera, true);
await tester.pump();
await tester.pumpWidget(build(fix2));
await tester.pump();
map = tester.widget<FlutterMap>(find.byType(FlutterMap));
final center = map.mapController!.camera.center;
expect(center.latitude, closeTo(fix1.latitude, 1e-9),
reason: 'following was cancelled by the manual pan; a later ambient fix '
'must not move the camera');
expect(center.longitude, closeTo(fix1.longitude, 1e-9));
});
testWidgets('the pulsing location marker falls back to the ambient position '
'when there are no recorded points', (tester) async {
const fix = ll.LatLng(51.0, -114.0);
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
showLocationMarker: true,
ambientPosition: fix,
),
),
));
await tester.pump();
expect(find.byKey(const Key('location-marker')), findsOneWidget);
});
testWidgets('no marker is shown when there is neither a recorded point nor an '
'ambient position', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: ripprTheme(),
home: const Scaffold(
body: RideMap(
points: [],
segments: [],
showEmptyLabel: false,
showLocationMarker: true,
),
),
));
await tester.pump();
expect(find.byKey(const Key('location-marker')), findsNothing);
});
});
}