Three pure modules: tile_math.dart (tilesForBounds/tilesAlongRoute, hard cap enforced by throwing), tile_cache.dart (FileTileCache with LRU eviction before any write that would exceed the cap), tile_downloader.dart (sequential, rate-limited, cooperatively cancellable, one bad tile doesn't abort the rest). CachedTileProvider wires the cache into flutter_map via a custom ImageProvider and now backs RideMap and RoutePlannerScreen's tile layers, so ordinary viewing write-throughs into the same capped cache. RoutePlannerScreen gained a route-corridor download action (no rectangle-selection UI -- the ticket names the corridor as strictly better and V3-07 already exists to hang it off of), with a count/size confirmation before any request and a cancellable progress dialog. Fixed a real bug before shipping: a StatefulBuilder-based progress dialog would have started a new overlapping download subscription on every single progress tick; moved to a dedicated StatefulWidget that subscribes once in initState. Marked partially done: aeroplane-mode verification on a real device is the ticket's own acceptance criterion and needs a phone this environment doesn't have.
206 lines
8.2 KiB
Dart
206 lines
8.2 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/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)));
|
|
|
|
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<Text>(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);
|
|
});
|
|
|
|
screenTest('the offline-tiles download button is disabled with no pins '
|
|
'(V3-11)', (tester) async {
|
|
final id = await repo.createRoutePlan(1000);
|
|
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
|
|
|
final button = tester.widget<IconButton>(
|
|
find.byKey(const Key('download-tiles')),
|
|
);
|
|
expect(button.onPressed, isNull);
|
|
expect(button.tooltip, contains('Add pins'));
|
|
});
|
|
|
|
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)));
|
|
|
|
final button = tester.widget<IconButton>(
|
|
find.byKey(const Key('download-tiles')),
|
|
);
|
|
expect(button.onPressed, isNotNull);
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|