T19/T21: map and export, completing Phase 4
flutter_map with the same OSM tiles and no-API-key reasoning that chose osmdroid. All four load-bearing behaviours ported and, unlike the native app's map, tested: one polyline per segment so a pause is a real gap, render-only decimation, the zoom clamp at OSM's max tile zoom with a short-ride fallback, and a real user agent. Speed colouring is bucketed per run rather than per-vertex, since neither osmdroid nor flutter_map makes per-vertex paint reasonable. Export via share_plus, which also handles the iPad popover anchor a naive port forgets. It passes the raw stored points, never the map's decimated path. 164 tests passing, analyze clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
120
test/ride_map_test.dart
Normal file
120
test/ride_map_test.dart
Normal file
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/domain/models.dart';
|
||||
import 'package:rippr/src/ui/components/ride_map.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');
|
||||
});
|
||||
}
|
||||
@@ -66,10 +66,13 @@ void main() {
|
||||
///
|
||||
/// The theme is deliberately included: the black-on-black bug was invisible to logic
|
||||
/// tests and only a rendered widget can catch its equivalent.
|
||||
Widget host(Widget child) => ProviderScope(
|
||||
Widget host(Widget child, {bool map = true}) => ProviderScope(
|
||||
overrides: [
|
||||
databaseProvider.overrideWithValue(db),
|
||||
locationSourceProvider.overrideWithValue(source),
|
||||
// The map is off by default in tests that are not about the map: it fetches
|
||||
// tiles, which a widget test cannot serve, and it changes scroll geometry.
|
||||
mapEnabledProvider.overrideWith((ref) => map),
|
||||
],
|
||||
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||
);
|
||||
@@ -286,7 +289,7 @@ void main() {
|
||||
final id = await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Sunday blast'), findsOneWidget);
|
||||
@@ -297,7 +300,7 @@ void main() {
|
||||
|
||||
screenTest('a missing ride says so instead of rendering blank',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999)));
|
||||
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||
@@ -307,7 +310,7 @@ void main() {
|
||||
final id = await seedCompletedTrip(
|
||||
startedAt: 1000, endedAt: 2000, points: 1);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Both charts need intervals; one point has none. An unexplained blank box would
|
||||
@@ -325,7 +328,7 @@ void main() {
|
||||
screenTest('renaming updates the title', (tester) async {
|
||||
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id)));
|
||||
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('rename')));
|
||||
|
||||
Reference in New Issue
Block a user