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.
This commit is contained in:
@@ -177,4 +177,57 @@ void main() {
|
||||
final waypoints = await db.waypointsForRoute(routeId);
|
||||
expect(waypoints, hasLength(1));
|
||||
});
|
||||
|
||||
test('a v3 database (FB-05) gains isClosedLoop defaulting to false', () async {
|
||||
// v3: identical to the v2 seed above, plus the route_plans/waypoints tables V3-07
|
||||
// added.
|
||||
seedV1Database();
|
||||
final raw = sqlite3.sqlite3.open(dbFile.path);
|
||||
raw.execute('''
|
||||
ALTER TABLE trips ADD COLUMN activity TEXT NOT NULL DEFAULT 'motorcycle';
|
||||
''');
|
||||
raw.execute('''
|
||||
CREATE TABLE route_plans (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
createdAt INTEGER NOT NULL,
|
||||
activity TEXT NOT NULL DEFAULT 'motorcycle',
|
||||
distanceM REAL NOT NULL DEFAULT 0,
|
||||
estimatedMillis INTEGER NULL,
|
||||
geometry TEXT NULL
|
||||
);
|
||||
CREATE TABLE waypoints (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
routeId INTEGER NOT NULL REFERENCES route_plans (id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
latitude REAL NOT NULL,
|
||||
longitude REAL NOT NULL,
|
||||
name TEXT NULL
|
||||
);
|
||||
CREATE INDEX idx_waypoints_route ON waypoints (routeId);
|
||||
''');
|
||||
raw.execute('''
|
||||
INSERT INTO route_plans (id, name, createdAt) VALUES (1, 'Existing route', 8000);
|
||||
''');
|
||||
raw.execute('PRAGMA user_version = 3;');
|
||||
raw.close();
|
||||
|
||||
final db = AppDatabase(NativeDatabase(dbFile));
|
||||
addTearDown(db.close);
|
||||
|
||||
// The pre-existing route survives with the new column defaulting to false.
|
||||
final existing = await db.getRoutePlan(1);
|
||||
expect(existing, isNotNull);
|
||||
expect(existing!.isClosedLoop, isFalse);
|
||||
|
||||
// A freshly inserted route also reads back false by default.
|
||||
final newId = await db.insertRoutePlan(
|
||||
const RoutePlan(name: 'New route', createdAt: 9000),
|
||||
);
|
||||
expect((await db.getRoutePlan(newId))!.isClosedLoop, isFalse);
|
||||
|
||||
// Toggling persists.
|
||||
await db.setRoutePlanClosedLoop(1, true);
|
||||
expect((await db.getRoutePlan(1))!.isClosedLoop, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,6 +113,61 @@ void main() {
|
||||
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');
|
||||
|
||||
@@ -274,6 +274,78 @@ void main() {
|
||||
expect(find.textContaining('MB'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('the "Close the loop" menu item is disabled with 0-1 waypoints, '
|
||||
'enabled with 2+ (FB-05)', (tester) async {
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
await repo.addWaypoint(id, 51.0, -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();
|
||||
|
||||
var item = tester.widget<PopupMenuItem<String>>(
|
||||
find.byKey(const Key('toggle-closed-loop')),
|
||||
);
|
||||
expect(item.enabled, isFalse);
|
||||
|
||||
// Close the (still-open) menu and add a second waypoint.
|
||||
await tester.tapAt(const Offset(10, 10));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await repo.addWaypoint(id, 51.01, -114.0);
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
|
||||
item = tester.widget<PopupMenuItem<String>>(
|
||||
find.byKey(const Key('toggle-closed-loop')),
|
||||
);
|
||||
expect(item.enabled, isTrue);
|
||||
});
|
||||
|
||||
screenTest('tapping "Close the loop" toggles isClosedLoop and flips the label '
|
||||
'(FB-05)', (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();
|
||||
|
||||
expect(find.text('Close the loop'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('toggle-closed-loop')));
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final route = await repo.routePlanById(id);
|
||||
expect(route?.isClosedLoop, isTrue);
|
||||
|
||||
await tester.tap(find.byKey(const Key('route-overflow-menu')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pump();
|
||||
expect(find.text('Open the loop'), findsOneWidget);
|
||||
});
|
||||
|
||||
screenTest('a closed loop draws one more polyline point than there are waypoints '
|
||||
'(FB-05)', (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 repo.addWaypoint(id, 51.02, -114.0);
|
||||
await repo.setClosedLoop(id, true);
|
||||
await pumpMap(tester, host(RoutePlannerScreen(routeId: id)));
|
||||
|
||||
final layer = tester.widget<PolylineLayer>(find.byType(PolylineLayer));
|
||||
expect(layer.polylines.single.points, hasLength(4));
|
||||
});
|
||||
|
||||
screenTest('the shell nav bar correctly shows Plan active on this screen '
|
||||
'(UI-06)', (tester) async {
|
||||
final id = await repo.createRoutePlan(1000);
|
||||
|
||||
Reference in New Issue
Block a user