V3-07: route drawing (pins and straight lines)
Route plans are their own entity (RoutePlan/Waypoint, schema v3), never joined into ride totals or the rides list. RoutePlanRepository recomputes straight-line distance after every waypoint mutation. RoutePlannerScreen supports tap-to-add, tap-to-delete, and hand-rolled drag-to-move via MapCamera's screen/latlng conversions. Fixed a real bug found by testing: FlutterMap's initialCenter/initialZoom are read once at construction, so building the map before the waypoints stream's first emission froze the camera on null-island. Also documents a genuine ten-minute test hang traced to Stream.first on a Drift watch() query inside testWidgets, which needs a pump-driven Timer that flutter_test's fake zone never advances on its own.
This commit is contained in:
@@ -147,4 +147,34 @@ void main() {
|
||||
expect(all.length, 3);
|
||||
expect(all.every((t) => t.activity == Activity.motorcycle), isTrue);
|
||||
});
|
||||
|
||||
test('a v2 database (V3-07) gains route_plans/waypoints and keeps its trips',
|
||||
() async {
|
||||
// v2: identical to the v1 seed above, plus the `activity` column V3-01 added.
|
||||
seedV1Database();
|
||||
final raw = sqlite3.sqlite3.open(dbFile.path);
|
||||
raw.execute('''
|
||||
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
|
||||
''');
|
||||
raw.execute('PRAGMA user_version = 2;');
|
||||
raw.close();
|
||||
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
addTearDown(db.close);
|
||||
|
||||
// The pre-existing ride is untouched by an upgrade that has nothing to do with it.
|
||||
final trip = await db.getTrip(1);
|
||||
expect(trip, isNotNull);
|
||||
expect(trip!.name, 'Old ride');
|
||||
|
||||
// The new tables are not just present but usable.
|
||||
final routeId = await db.insertRoutePlan(
|
||||
const RoutePlan(name: 'Coast loop', createdAt: 9000),
|
||||
);
|
||||
await db.insertWaypoint(
|
||||
Waypoint(routeId: routeId, ordinal: 0, latitude: 51.0, longitude: -114.0),
|
||||
);
|
||||
final waypoints = await db.waypointsForRoute(routeId);
|
||||
expect(waypoints, hasLength(1));
|
||||
});
|
||||
}
|
||||
|
||||
133
test/route_plan_repository_test.dart
Normal file
133
test/route_plan_repository_test.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:rippr/src/data/database.dart';
|
||||
import 'package:rippr/src/data/route_plan_repository.dart';
|
||||
import 'package:rippr/src/data/trip_repository.dart';
|
||||
import 'package:rippr/src/geo/geo.dart' as geo;
|
||||
|
||||
/// V3-07: mirrors trip_repository_test.dart's shape -- in-memory Drift, real repository,
|
||||
/// no mocks. A route plan has no state machine, so most of what is tested here is
|
||||
/// straight-line distance staying correct after every mutation.
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late RoutePlanRepository routes;
|
||||
late TripRepository trips;
|
||||
|
||||
setUp(() {
|
||||
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||
db = AppDatabase(NativeDatabase.memory());
|
||||
routes = RoutePlanRepository(db);
|
||||
trips = TripRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
test('a new route plan has no waypoints and zero distance', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
final plan = await routes.routePlanById(id);
|
||||
|
||||
expect(plan, isNotNull);
|
||||
expect(plan!.distanceM, 0.0);
|
||||
expect(await routes.waypointsFor(id), isEmpty);
|
||||
});
|
||||
|
||||
test('adding waypoints appends in order and updates distance live', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
|
||||
await routes.addWaypoint(id, 51.0, -114.0);
|
||||
await routes.addWaypoint(id, 51.01, -114.0);
|
||||
await routes.addWaypoint(id, 51.02, -114.0);
|
||||
|
||||
final waypoints = await routes.waypointsFor(id);
|
||||
expect(waypoints.map((w) => w.ordinal), [0, 1, 2]);
|
||||
|
||||
final expected = geo.pathLengthMeters([
|
||||
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
|
||||
]);
|
||||
final plan = await routes.routePlanById(id);
|
||||
expect(plan!.distanceM, expected);
|
||||
expect(expected, greaterThan(0));
|
||||
});
|
||||
|
||||
test('moving a waypoint updates distance', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
await routes.addWaypoint(id, 51.0, -114.0);
|
||||
await routes.addWaypoint(id, 51.01, -114.0);
|
||||
final before = (await routes.routePlanById(id))!.distanceM;
|
||||
|
||||
final target = (await routes.waypointsFor(id)).last;
|
||||
await routes.moveWaypoint(id, target.id, 52.0, -114.0);
|
||||
|
||||
final after = (await routes.routePlanById(id))!.distanceM;
|
||||
expect(after, isNot(before));
|
||||
});
|
||||
|
||||
test('deleting a waypoint closes the ordinal gap and updates distance', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
await routes.addWaypoint(id, 51.0, -114.0);
|
||||
await routes.addWaypoint(id, 51.01, -114.0);
|
||||
await routes.addWaypoint(id, 51.02, -114.0);
|
||||
|
||||
final middle = (await routes.waypointsFor(id))[1];
|
||||
await routes.deleteWaypoint(id, middle.id);
|
||||
|
||||
final remaining = await routes.waypointsFor(id);
|
||||
expect(remaining.length, 2);
|
||||
expect(remaining.map((w) => w.ordinal), [0, 1]);
|
||||
|
||||
final expected = geo.pathLengthMeters([
|
||||
for (final w in remaining) geo.LatLon(w.latitude, w.longitude),
|
||||
]);
|
||||
expect((await routes.routePlanById(id))!.distanceM, expected);
|
||||
});
|
||||
|
||||
test('reordering renumbers ordinals and leaves distance internally consistent',
|
||||
() async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
await routes.addWaypoint(id, 51.0, -114.0);
|
||||
await routes.addWaypoint(id, 51.01, -114.0);
|
||||
await routes.addWaypoint(id, 51.02, -114.0);
|
||||
|
||||
await routes.reorderWaypoint(id, 0, 2);
|
||||
|
||||
final reordered = await routes.waypointsFor(id);
|
||||
expect(reordered.map((w) => w.ordinal), [0, 1, 2]);
|
||||
expect(reordered.map((w) => w.latitude), [51.01, 51.02, 51.0]);
|
||||
});
|
||||
|
||||
test('renaming updates the row', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
await routes.renameRoutePlan(id, 'Coast loop');
|
||||
expect((await routes.routePlanById(id))!.name, 'Coast loop');
|
||||
});
|
||||
|
||||
test('deleting a route plan cascades to its waypoints', () async {
|
||||
final id = await routes.createRoutePlan(1000);
|
||||
await routes.addWaypoint(id, 51.0, -114.0);
|
||||
await routes.addWaypoint(id, 51.01, -114.0);
|
||||
|
||||
await routes.deleteRoutePlan(id);
|
||||
|
||||
expect(await routes.routePlanById(id), isNull);
|
||||
expect(await db.waypointsForRoute(id), isEmpty);
|
||||
});
|
||||
|
||||
test('route plans never appear alongside trips and never affect ride totals',
|
||||
() async {
|
||||
await routes.createRoutePlan(1000, name: 'Plan A');
|
||||
final routeId = await routes.createRoutePlan(2000, name: 'Plan B');
|
||||
await routes.addWaypoint(routeId, 51.0, -114.0);
|
||||
await routes.addWaypoint(routeId, 52.0, -114.0);
|
||||
|
||||
final h = await trips.startTrip(3000);
|
||||
await trips.appendPoints([]);
|
||||
await trips.completeTrip(4000);
|
||||
|
||||
final completedTrips = await trips.db.watchCompletedTrips().first;
|
||||
expect(completedTrips.length, 1);
|
||||
expect(completedTrips.single.id, h.tripId);
|
||||
// Nothing about a route plan's distance leaked into the trip.
|
||||
expect(completedTrips.single.distanceM, 0.0);
|
||||
});
|
||||
}
|
||||
168
test/route_planner_screen_test.dart
Normal file
168
test/route_planner_screen_test.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -218,6 +218,20 @@ void main() {
|
||||
reason: 'the button must actually invoke the callback that navigates');
|
||||
});
|
||||
|
||||
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
|
||||
var opened = false;
|
||||
await tester.pumpWidget(
|
||||
host(RecordScreen(onOpenRoutes: () => opened = true)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('open-routes')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('open-routes')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(opened, isTrue);
|
||||
});
|
||||
|
||||
screenTest('the live map appears only while recording and the toggle is on '
|
||||
'(V3-04)', (tester) async {
|
||||
// Idle, toggle on: no trip to draw, so no map at all.
|
||||
|
||||
Reference in New Issue
Block a user