Files
rippr/test/route_planner_screen_test.dart
uhryniuk e491c9ee65 FB-04: fix Route Planner map failing to render on cold navigation
routePlanProvider's AsyncLoading state collapsed with "route doesn't exist"
via .valueOrNull, so a brand-new route's screen briefly rendered "This route
no longer exists." with no FlutterMap in the tree before the DB stream's
first emission arrived. Add a hasValue guard mirroring waypointsAsync's
existing pattern, and replace the route planner's hardcoded initialZoom of
14 with FB-01's ambientZoom constant for street-level parity with the rest
of the app.
2026-08-24 16:12:06 -05:00

298 lines
13 KiB
Dart

import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:rippr/src/app/providers.dart';
import 'package:rippr/src/data/database.dart';
import 'package:rippr/src/data/route_plan_repository.dart';
import 'package:rippr/src/ui/app_shell.dart';
import 'package:rippr/src/ui/components/floating_pill.dart';
import 'package:rippr/src/ui/components/ride_map.dart' show ambientZoom;
import 'package:rippr/src/ui/format.dart';
import 'package:rippr/src/ui/routes/route_planner_screen.dart';
import 'package:rippr/src/ui/routes/routes_list_screen.dart';
import 'package:rippr/src/ui/theme.dart';
/// V3-07: the repository is already covered end to end in
/// route_plan_repository_test.dart (including "routes never appear in the rides list");
/// this covers the screens that drive it.
void main() {
late AppDatabase db;
late RoutePlanRepository repo;
setUp(() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
db = AppDatabase(NativeDatabase.memory());
repo = RoutePlanRepository(db);
});
tearDown(() async => db.close());
Widget host(Widget child) => ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
child: MaterialApp(theme: ripprTheme(), home: child),
);
/// Same shape as widget_test.dart's `screenTest`: Drift's stream-query cache keeps a
/// query alive briefly after its last listener leaves, which reads to `flutter_test`
/// as a pending Timer unless the tree is fully torn down and pumped past the
/// keep-alive window first.
void screenTest(String description, Future<void> Function(WidgetTester) body) {
testWidgets(description, (tester) async {
await body(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(seconds: 2));
});
}
group('RoutesListScreen', () {
screenTest('empty state explains what to do', (tester) async {
await tester.pumpWidget(host(const RoutesListScreen()));
await tester.pumpAndSettle();
expect(find.textContaining('No routes yet'), findsOneWidget);
});
screenTest('tapping new route creates one and opens it', (tester) async {
int? opened;
await tester.pumpWidget(
host(RoutesListScreen(onOpenRoute: (id) => opened = id)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-route')));
await tester.pumpAndSettle();
expect(opened, isNotNull);
// Not `db.watchRoutePlans().first`: a fresh Stream subscription's first emission
// depends on a Timer inside Drift's stream-query store that never fires under
// flutter_test's fake zone without a pump driving it -- this hung for a real ten
// minutes before being traced back to exactly that. A plain Future-returning call
// has no such dependency.
final plan = await repo.routePlanById(opened!);
expect(plan, isNotNull);
});
screenTest('deleting a route removes it from the list', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Coast loop');
await tester.pumpWidget(host(const RoutesListScreen()));
await tester.pumpAndSettle();
expect(find.text('Coast loop'), findsOneWidget);
await tester.tap(find.byKey(Key('delete-route-$id')));
await tester.pumpAndSettle();
expect(find.text('Coast loop'), findsNothing);
expect(await repo.routePlanById(id), isNull);
});
});
group('RoutePlannerScreen', () {
// A real TileLayer tries real network fetches that never resolve in the test
// harness, so `pumpAndSettle` would hang forever waiting for it to go idle --
// exactly the reason record_screen's own map tests use a bounded pump instead (see
// `pumpLive` in widget_test.dart). A few explicit frames are enough here too.
Future<void> pumpMap(WidgetTester tester, Widget widget) async {
await tester.pumpWidget(widget);
// The waypoints stream (and therefore the map's initial camera, which is read
// only once at construction -- see the screen's own comment) needs a handful of
// frames to resolve its first value; a bounded loop rather than pumpAndSettle,
// since once the map is up its TileLayer never goes idle in this harness.
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
}
screenTest('tapping the map adds a pin and the distance label updates',
(tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// UI-06: no pins yet -- the "tap to drop a pin" tooltip shows, not a stat pill
// (there is nothing to summarise yet).
expect(find.textContaining('TAP TO DROP A PIN'), findsOneWidget);
expect(find.byType(FloatingPill), findsNothing);
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(
tester.getCenter(find.byType(FlutterMap)) + const Offset(40, 40),
);
await tester.pump(const Duration(milliseconds: 50));
final waypoints = await repo.waypointsFor(id);
expect(waypoints, hasLength(2));
// The tooltip is gone and the stat pill has taken its place, showing the real
// pin count and a non-zero distance. Read the pill's own `stats` directly
// rather than `find.text('2')` -- the second waypoint pin's own marker label
// is also "2", so that text is no longer unique on screen.
expect(find.textContaining('TAP TO DROP A PIN'), findsNothing);
final pill = tester.widget<FloatingPill>(find.byType(FloatingPill));
final pinsStat = pill.stats.firstWhere((s) => s.label == 'Pins');
final distanceStat = pill.stats.firstWhere((s) => s.label == 'Distance');
expect(pinsStat.value, '2');
expect(distanceStat.value, isNot(formatDistance(0)),
reason: 'the distance stat must reflect the two real pins, not stay zeroed');
});
screenTest('tapping a pin deletes it', (tester) async {
// UI-06: a single waypoint (rather than two) -- with only one pin, the camera
// centres exactly on it, keeping it clear of the floating "Start Route" button
// now anchored at the bottom of the screen; two close pins previously landed
// one of them directly underneath it in this test's fixed viewport.
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// flutter_map's `Marker` is a plain data class, not a Widget -- it never appears
// in the tree itself. `CircleAvatar` is what `_WaypointPin` actually renders.
expect(find.byType(CircleAvatar), findsOneWidget);
// Not `find.text('1')`: the FloatingPill's "Pins" stat also reads "1" with a
// single waypoint, so the pin's own label text is no longer a unique match.
await tester.tap(find.byType(CircleAvatar));
await tester.pump();
expect(await repo.waypointsFor(id), isEmpty);
});
screenTest('renaming writes through to the repository (UI-06: no app bar title '
'to display it on any more)', (tester) async {
final id = await repo.createRoutePlan(1000, name: 'Old name');
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
// Rename now lives behind the floating overflow menu -- open it first.
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.tap(find.byKey(const Key('rename-route')));
await tester.pump(const Duration(milliseconds: 300));
await tester.enterText(
find.byKey(const Key('route-name-field')),
'Sunday coast run',
);
await tester.tap(find.text('Save'));
await tester.pump();
final renamed = await repo.routePlanById(id);
expect(renamed?.name, 'Sunday coast run');
});
screenTest('a missing route says so instead of a blank map', (tester) async {
await tester.pumpWidget(host(const RoutePlannerScreen(routeId: 9999)));
await tester.pumpAndSettle();
expect(find.textContaining('no longer exists'), findsOneWidget);
});
screenTest(
'a brand-new route never flashes "no longer exists" while its stream is '
'still resolving, and shows the real map once it settles (FB-04)',
(tester) async {
// Mirrors the real "+ New route" flow: `repo.createRoutePlan` returns as soon as
// the row is written, but `routePlanProvider`'s DB stream hasn't delivered its
// first emission by the very next frame -- both `routePlanProvider` and
// `routeWaypointsProvider` start out `AsyncLoading` on this fresh navigation.
final id = await repo.createRoutePlan(1000, name: 'Fresh route');
await tester.pumpWidget(host(RoutePlannerScreen(routeId: id)));
// Immediately after the very first frame -- before the stream has had any chance
// to deliver -- the loading spinner must show, never the "no longer exists" text.
// (Pre-fix, `route` collapsed "still loading" and "doesn't exist" into the same
// null via `.valueOrNull`, so this assertion is exactly the FB-04 regression.)
expect(find.textContaining('no longer exists'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.byType(FlutterMap), findsNothing);
// Once both streams deliver their first value, the real map canvas appears.
for (var i = 0; i < 10; i++) {
await tester.pump(const Duration(milliseconds: 50));
}
expect(find.textContaining('no longer exists'), findsNothing);
expect(find.byType(FlutterMap), findsOneWidget);
});
screenTest('a route with no waypoints opens at street-level zoom, not zoomed out '
'(FB-04)', (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
expect(map.options.initialZoom, ambientZoom);
});
screenTest('the offline-tiles download menu item is disabled with no pins '
'(V3-11, UI-06: now a PopupMenuItem behind the overflow menu)',
(tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
final item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('download-tiles')),
);
expect(item.enabled, isFalse);
});
screenTest('downloading shows a tile count and size estimate before any '
'request (V3-11)', (tester) async {
final id = await repo.createRoutePlan(1000);
await repo.addWaypoint(id, 51.0, -114.0);
await repo.addWaypoint(id, 51.01, -114.0);
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
await tester.tap(find.byKey(const Key('route-overflow-menu')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
final item = tester.widget<PopupMenuItem<String>>(
find.byKey(const Key('download-tiles')),
);
expect(item.enabled, isTrue);
await tester.tap(find.byKey(const Key('download-tiles')));
// Bounded, not pumpAndSettle: the confirmation dialog is safe to settle (no
// network involved yet), but this test stops before confirming, precisely to
// avoid needing a fake HTTP layer for what the pure downloadTiles/tile_cache/
// tile_math test suites already cover directly.
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
expect(find.textContaining('Download'), findsWidgets);
expect(find.textContaining('tiles?'), findsOneWidget);
expect(find.textContaining('MB'), findsOneWidget);
});
screenTest('the shell nav bar correctly shows Plan active on this screen '
'(UI-06)', (tester) async {
final id = await repo.createRoutePlan(1000);
await pumpMap(
tester,
host(
ShellScaffold(
currentIndex: 2, // Map, Rides, Plan, Settings -- Plan is index 2.
onDestinationSelected: (_) {},
child: RoutePlannerScreen(routeId: id),
),
),
);
final navBar = tester.widget<NavigationBar>(find.byKey(const Key('shell-nav-bar')));
expect(navBar.selectedIndex, 2,
reason: 'not the mockup\'s own generation error (it showed Rides active '
'while viewing Plan) -- the shipped nav bar must reflect the real tab');
});
});
}