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/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 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 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(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('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>( 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>( 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(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'); }); }); }