Files
rippr/test/route_plan_repository_test.dart
uhryniuk 9ac0be6ee5 FB-05: closed-loop routes in Route Planner
Add an isClosedLoop toggle to route plans (schema v3 -> v4 migration),
threaded through the domain model, repository distance recomputation,
the overflow menu, and the drawn polyline, so a route can loop back to
its first waypoint.
2026-08-24 16:22:51 -05:00

189 lines
7.1 KiB
Dart

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('setClosedLoop(true) adds the closing segment to 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 open = (await routes.routePlanById(id))!.distanceM;
final waypoints = await routes.waypointsFor(id);
final closingSegment = geo.pathLengthMeters([
geo.LatLon(waypoints.last.latitude, waypoints.last.longitude),
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
]);
await routes.setClosedLoop(id, true);
final closed = (await routes.routePlanById(id))!.distanceM;
expect(closed, closeTo(open + closingSegment, 1e-6));
expect((await routes.routePlanById(id))!.isClosedLoop, isTrue);
});
test('setClosedLoop(false) after true returns distance to the open total', () 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 open = (await routes.routePlanById(id))!.distanceM;
await routes.setClosedLoop(id, true);
expect((await routes.routePlanById(id))!.distanceM, isNot(open));
await routes.setClosedLoop(id, false);
expect((await routes.routePlanById(id))!.distanceM, closeTo(open, 1e-6));
expect((await routes.routePlanById(id))!.isClosedLoop, isFalse);
});
test('adding a waypoint to a closed-loop route keeps the closing segment correct',
() 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.setClosedLoop(id, true);
// Adding a third waypoint should recompute the closing segment against the new
// last waypoint, not the old one.
await routes.addWaypoint(id, 51.02, -114.0);
final waypoints = await routes.waypointsFor(id);
final expected = geo.pathLengthMeters([
for (final w in waypoints) geo.LatLon(w.latitude, w.longitude),
geo.LatLon(waypoints.first.latitude, waypoints.first.longitude),
]);
expect((await routes.routePlanById(id))!.distanceM, closeTo(expected, 1e-6));
});
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);
});
}