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/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))); expect(find.text('0 m'), findsOneWidget); expect(find.textContaining('0 pins'), findsOneWidget); 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)); expect(find.textContaining('2 pins'), findsOneWidget); final label = tester.widget(find.byKey(const Key('route-distance'))); expect(label.data, isNot('0 m')); }); screenTest('tapping a pin deletes it', (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))); // 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), findsNWidgets(2)); await tester.tap(find.text('1')); // the first pin's label await tester.pump(); expect(await repo.waypointsFor(id), hasLength(1)); }); screenTest('renaming updates the app bar title', (tester) async { final id = await repo.createRoutePlan(1000, name: 'Old name'); await pumpMap(tester, host(RoutePlannerScreen(routeId: id))); await tester.tap(find.byKey(const Key('rename-route'))); await tester.pump(); await tester.enterText( find.byKey(const Key('route-name-field')), 'Sunday coast run', ); await tester.tap(find.text('Save')); await tester.pump(); expect(find.text('Sunday coast run'), findsOneWidget); }); 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); }); }); }