feat(train): routes kept as sealed rows, planned on the map (ADR 0030, D44)

Routes are synced `route` rows (the model `SavedRoute`, as Flutter has a
`Route`), sealed as plans are; the migration `routes` only creates the
table, its space index and its cascade to the sync engine's spaces. A
route's payload holds its name, activity, profile, waypoints and the way
planned, points and elevations delta-coded and gzipped.

Home gets a fourth tab, Routes, and the planner: waypoints tapped on the
map, held and dragged, the last undone, a new route's first where the
phone is (GPS through the recorder's channel), the way planned by the
instance's BRouter after each change, an earlier answer dropped; its
distance, climb, descent, elevation and surfaces under the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-29 00:37:44 +01:00
commit aea99c68ca
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 24035 additions and 12 deletions

View file

@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_analysis.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
/// What a route's page shows of the way planned (ADR 0030, D44).
void main() {
const route = RouteGeometry(
// 0.01° of latitude apart: about 1.11 km each.
points: [LatLng(48, 11), LatLng(48.01, 11), LatLng(48.02, 11)],
elevations: [500, 520, 510],
distanceMetres: 2224,
stretches: [
RouteStretch(metres: 900, surface: 'asphalt'),
RouteStretch(metres: 400, surface: 'compacted'),
RouteStretch(metres: 300, surface: 'paving_stones'),
RouteStretch(metres: 500),
RouteStretch(metres: 124, surface: 'grass'),
],
);
test('the elevation is drawn over the kilometres gone', () {
final profile = elevationProfile(route);
expect([for (final point in profile) point.value], [500, 520, 510]);
expect(profile.first.x, 0);
expect(profile[1].x, closeTo(1.112, 0.001));
expect(profile[2].x, closeTo(2.224, 0.001));
});
test('a route without elevations has no profile', () {
expect(
elevationProfile(
const RouteGeometry(
points: [LatLng(48, 11), LatLng(48.01, 11)],
distanceMetres: 1112,
),
),
isEmpty,
);
});
test("surfaces are grouped by OpenStreetMap's tags, the most first", () {
expect(surfaceShares(route), [
(SurfaceKind.paved, 1200),
(SurfaceKind.unknown, 500),
(SurfaceKind.compacted, 400),
(SurfaceKind.natural, 124),
]);
});
}

View file

@ -0,0 +1,253 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/bloc/route_editor_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
import '../../support/fake_routing_gateway.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_route_gateway.dart';
/// Planning a route (ADR 0030, D44): waypoints tapped, dragged and undone,
/// the first where the phone is, the way planned by the instance after
/// each change, kept once named.
void main() {
late FakeRoutingGateway routing;
late MemoryRouteGateway rows;
late TrainKeys keys;
late RouteRepository routes;
const home = LatLng(48.137, 11.575);
const park = LatLng(48.15, 11.59);
const river = LatLng(48.12, 11.58);
const fresh = RouteContent(
name: '',
profile: RouteProfile.run,
waypoints: [],
);
setUp(() async {
routing = FakeRoutingGateway();
rows = MemoryRouteGateway();
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
await keys.refresh();
routes = RouteRepository(rows, keys);
});
tearDown(() async {
await routes.dispose();
keys.dispose();
});
RouteEditorCubit editor({
RouteContent route = fresh,
String? id,
Future<LatLng?> Function()? here,
}) => RouteEditorCubit(routes, routing, route: route, id: id, here: here);
test('a new route starts where the phone is', () async {
final cubit = editor(here: () async => home);
await cubit.open();
expect(cubit.state.offered, isTrue);
expect(cubit.state.route.waypoints, [home]);
expect(cubit.state.route.planned, isNull);
expect(routing.asked, isEmpty);
await cubit.close();
});
test('an instance that plans no routes says so, and the phone is not '
'asked where it is', () async {
routing.isOffered = false;
var asked = false;
final cubit = editor(
here: () async {
asked = true;
return home;
},
);
expect(cubit.state.offered, isNull);
await cubit.open();
expect(cubit.state.offered, isFalse);
expect(asked, isFalse);
expect(cubit.state.route.waypoints, isEmpty);
await cubit.close();
});
test('a new route starts empty where the phone has no fix, or none '
'is asked', () async {
for (final here in [Future<LatLng?>.value, null]) {
final cubit = editor(here: here);
await cubit.open();
expect(cubit.state.route.waypoints, isEmpty);
await cubit.close();
}
});
test('a route opened to be changed keeps its waypoints and plans nothing '
'until changed', () async {
final kept = RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: const [home, park],
planned: straightRoute(const [home, park]),
);
final cubit = editor(route: kept, id: 'r1', here: () async => river);
await cubit.open();
expect(cubit.state.route, kept);
expect(routing.asked, isEmpty);
await cubit.close();
});
test('a second waypoint plans the way, each change plans it again', () async {
final cubit = editor();
await cubit.open();
await cubit.add(home);
expect(routing.asked, isEmpty);
await cubit.add(park);
expect(routing.asked.last.waypoints, [home, park]);
expect(cubit.state.route.planned, straightRoute(const [home, park]));
await cubit.add(river);
await cubit.move(1, const LatLng(48.16, 11.6));
expect(routing.asked.last.waypoints, [
home,
const LatLng(48.16, 11.6),
river,
]);
await cubit.undo();
expect(cubit.state.route.waypoints, [home, const LatLng(48.16, 11.6)]);
expect(routing.asked, hasLength(4));
await cubit.undo();
expect(cubit.state.offered, isTrue);
expect(cubit.state.route.waypoints, [home]);
expect(cubit.state.route.planned, isNull);
expect(routing.asked, hasLength(4));
await cubit.close();
});
test('the activity picks its profile, which the person can change and '
'which plans again', () async {
final cubit = editor();
await cubit.open();
await cubit.add(home);
await cubit.add(park);
await cubit.setActivity(Activity.cycling);
expect(cubit.state.route.profile, RouteProfile.trekkingBike);
expect(routing.asked.last.profile, RouteProfile.trekkingBike);
await cubit.setProfile(RouteProfile.gravel);
expect(cubit.state.route.activity, Activity.cycling);
expect(routing.asked.last.profile, RouteProfile.gravel);
await cubit.close();
});
test(
'an answer to an earlier change is dropped once a later one is asked',
() async {
final cubit = editor(route: fresh.copyWithWaypoints(const [home]));
await cubit.open();
routing.held = true;
final first = cubit.add(park);
final second = cubit.add(river);
routing.release();
await Future.wait([first, second]);
expect(cubit.state.planning, isFalse);
expect(
cubit.state.route.planned,
straightRoute(const [home, park, river]),
);
await cubit.close();
},
);
test('a refusal says why, and leaves no way planned to be kept', () async {
final cubit = editor(route: fresh.copyWithWaypoints(const [home]));
await cubit.open();
cubit.rename('Into the lake');
routing.refusal = RoutingRefusal.noRoute;
await cubit.add(park);
expect(cubit.state.refusal, RoutingRefusal.noRoute);
expect(cubit.state.route.planned, isNull);
expect(cubit.state.canSave, isFalse);
routing.refusal = null;
await cubit.move(1, river);
expect(cubit.state.refusal, isNull);
expect(cubit.state.canSave, isTrue);
await cubit.close();
});
test('a route is kept once named and planned, and changed in place '
'after', () async {
final cubit = editor();
await cubit.open();
await cubit.add(home);
await cubit.add(park);
expect(cubit.state.canSave, isFalse);
cubit.rename(' Park ');
expect(await cubit.save(), isTrue);
final id = cubit.state.id!;
expect((await routes.all()).single.content!.name, 'Park');
await cubit.add(river);
expect(await cubit.save(), isTrue);
final kept = (await routes.all()).single;
expect(kept.id, id);
expect(kept.content!.waypoints, [home, park, river]);
await cubit.close();
});
test('nothing is kept on a device that cannot seal', () async {
final locked = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => null,
);
await locked.refresh();
final cubit = RouteEditorCubit(
RouteRepository(rows, locked),
routing,
route: RouteContent(
name: 'Park',
profile: RouteProfile.run,
waypoints: const [home, park],
planned: straightRoute(const [home, park]),
),
);
expect(await cubit.save(), isFalse);
expect(rows.rows, isEmpty);
await cubit.close();
locked.dispose();
});
}
extension on RouteContent {
RouteContent copyWithWaypoints(List<LatLng> waypoints) => RouteContent(
name: name,
activity: activity,
profile: profile,
waypoints: waypoints,
);
}

View file

@ -0,0 +1,133 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_payload.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
import 'package:latlong2/latlong.dart';
/// A route's payload (ADR 0030, D44): versioned JSON, its points to a
/// micro-degree and its elevations to a decimetre, delta-coded, gzipped.
void main() {
const route = RouteContent(
name: 'Along the Isar',
activity: Activity.running,
profile: RouteProfile.run,
waypoints: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
planned: RouteGeometry(
points: [
LatLng(48.137154, 11.576124),
LatLng(48.13, 11.58),
LatLng(48.1201, 11.5832),
],
elevations: [519.5, 517.25, 512],
distanceMetres: 2050,
ascentMetres: 3,
descentMetres: 10,
hints: [
TurnHint(index: 1, kind: TurnKind.slightRight, metres: 1200, angle: 20),
TurnHint(
index: 2,
kind: TurnKind.roundabout,
exit: 2,
metres: 0,
angle: -90,
),
],
stretches: [
RouteStretch(metres: 1200, surface: 'asphalt', highway: 'cycleway'),
RouteStretch(metres: 850, highway: 'path'),
],
),
);
test('a route comes back as it went, to a micro-degree and a decimetre', () {
final back = decodeRoute(encodeRoute(route))!;
expect(back.name, route.name);
expect(back.activity, Activity.running);
expect(back.profile, RouteProfile.run);
expect(back.waypoints, route.waypoints);
expect(back.planned!.points, route.planned!.points);
expect(back.planned!.elevations, [519.5, 517.3, 512]);
expect(back.planned!.distanceMetres, 2050);
expect(back.planned!.ascentMetres, 3);
expect(back.planned!.descentMetres, 10);
expect(back.planned!.hints, route.planned!.hints);
expect(back.planned!.stretches, route.planned!.stretches);
});
test('a route not planned yet keeps its waypoints alone', () {
const drawn = RouteContent(
name: 'Loop',
profile: RouteProfile.gravel,
waypoints: [LatLng(48, 11)],
);
expect(decodeRoute(encodeRoute(drawn)), drawn);
});
test('a payload is gzipped JSON with its schema', () {
final json = jsonDecode(
utf8.decode(
GZipCodec().decode(
Uint8List.sublistView(encodeRoute(route)),
),
),
) as Map<String, Object?>;
expect(json['schemaVersion'], routeSchemaVersion);
});
ByteData payloadOf(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(GZipCodec().encode(utf8.encode(jsonEncode(json)))),
);
test(
'one of a newer schema, a blank one or one unreadable reads as none',
() {
expect(
decodeRoute(payloadOf({'schemaVersion': routeSchemaVersion + 1})),
isNull,
);
expect(decodeRoute(ByteData(0)), isNull);
expect(
decodeRoute(ByteData.sublistView(Uint8List.fromList([1, 2]))),
isNull,
);
},
);
test('an unknown profile is the activity’s, an unknown turn left out', () {
final back = decodeRoute(
payloadOf({
'schemaVersion': 1,
'name': 'Ride',
'activity': 'cycling',
'profile': 'tandem',
'waypoints': {
'lat': [48000000, 1000],
'lon': [11000000, 1000],
},
'planned': {
'lat': [48000000, 1000],
'lon': [11000000, 1000],
'ele': <int>[],
'distance': 150,
'hints': [
{'index': 1, 'kind': 'teleport', 'metres': 0},
{'index': 1, 'kind': 'left', 'metres': 0},
],
},
}),
)!;
expect(back.profile, RouteProfile.trekkingBike);
expect(back.planned!.hints, [
const TurnHint(index: 1, kind: TurnKind.left, metres: 0),
]);
expect(back.planned!.points.last, const LatLng(48.001, 11.001));
});
}

View file

@ -0,0 +1,123 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_route_gateway.dart';
/// The person's routes, sealed on the way to the instance and opened
/// on the way back (ADR 0030, D7, D44), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryRouteGateway rows;
final opened = <TrainKeys>[];
const isar = RouteContent(
name: 'Along the Isar',
profile: RouteProfile.run,
waypoints: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
planned: RouteGeometry(
points: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
elevations: [519.5, 512],
distanceMetres: 2050,
descentMetres: 7,
),
);
Future<RouteRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return RouteRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryRouteGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a route made on one device reads on another of the account, '
'edited and deleted there', () async {
final laptop = await device(alice);
final id = await laptop.save(isar);
final phone = await device(alice);
expect(await phone.all(), [StoredRoute(id, isar)]);
const renamed = RouteContent(
name: 'Isar, short',
profile: RouteProfile.hike,
waypoints: [LatLng(48, 11)],
);
await phone.update(id, renamed);
expect(await laptop.all(), [StoredRoute(id, renamed)]);
await laptop.delete(id);
expect(await phone.all(), isEmpty);
});
test('nothing the instance gets is readable: no name, no JSON', () async {
await (await device(alice)).save(isar);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
expect(
utf8.decode(Uint8List.sublistView(sealed.payload), allowMalformed: true),
isNot(contains('Isar')),
);
});
test("another person's route, a readable one, or one moved to another row "
'shows locked, never as theirs', () async {
final id = await (await device(alice)).save(isar);
final sealed = rows.rows[id]!;
rows.rows
..['moved'] = sealed
..['readable'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(utf8.encode('{"schemaVersion":1}')),
),
);
final others = await device(bob);
expect(
(await others.all()).where((route) => route.content != null),
isEmpty,
);
final mine = await device(alice);
expect(
{for (final route in await mine.all()) route.id: route.content},
{id: isar, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and saves nothing', () async {
await (await device(alice)).save(isar);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(isar), throwsA(isA<KeysMissing>()));
});
}

View file

@ -0,0 +1,104 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
import 'package:halkyon_train_flutter/src/feature/route/data/sync_route_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
import 'package:latlong2/latlong.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The routes on the device's own database, as the sync engine keeps them,
// with libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late RouteRepository routes;
const isar = RouteContent(
name: 'Along the Isar',
profile: RouteProfile.run,
waypoints: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
planned: RouteGeometry(
points: [LatLng(48.137154, 11.576124), LatLng(48.1201, 11.5832)],
elevations: [519.5, 512],
distanceMetres: 2050,
descentMetres: 7,
),
);
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('routes');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
routes = RouteRepository(SyncRouteGateway(db, sync), keys);
});
tearDown(() async {
await routes.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a route is kept, edited and read back opened', () async {
final id = await routes.save(isar);
const renamed = RouteContent(
name: 'Isar, short',
profile: RouteProfile.hike,
waypoints: [LatLng(48, 11)],
);
await routes.update(id, renamed);
expect(await routes.all(), [StoredRoute(id, renamed)]);
});
test('deleting a route blanks its payload first', () async {
final id = await routes.save(isar);
final kept = await routes.save(isar);
await routes.delete(id);
expect(await routes.all(), [StoredRoute(kept, isar)]);
// The sync engine keeps the deleted row as its tombstone: blank.
final rows = await db.db.unsafeQuery(
'SELECT length("payload"), "keyId", "nonce" FROM "route"',
);
final blank = [
for (final row in rows)
if (row[0] == 0) row,
];
expect(rows.length - blank.length, 1);
expect(blank, hasLength(1));
expect(blank.single.skip(1), [null, null]);
});
}