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:
parent
a8f7c26e69
commit
aea99c68ca
5 changed files with 24035 additions and 12 deletions
|
|
@ -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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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>()));
|
||||
});
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue