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
9 changed files with 24035 additions and 12 deletions
|
|
@ -30,19 +30,25 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.da
|
|||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.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/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../support/fake_firmware_relay.dart';
|
||||
import '../support/fake_health_platform.dart';
|
||||
import '../support/fake_loop_platform.dart';
|
||||
import '../support/fake_recorder_platform.dart';
|
||||
import '../support/fake_routing_gateway.dart';
|
||||
import '../support/memory_content_key_gateway.dart';
|
||||
import '../support/memory_day_gateway.dart';
|
||||
import '../support/memory_plan_gateway.dart';
|
||||
import '../support/memory_profile_gateway.dart';
|
||||
import '../support/memory_route_gateway.dart';
|
||||
import '../support/memory_workout_gateway.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -76,6 +82,8 @@ void main() {
|
|||
DayRepository? days,
|
||||
PolarLoop? loop,
|
||||
PlanRepository? plans,
|
||||
RouteRepository? routes,
|
||||
RoutingGateway? routing,
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(412, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -176,6 +184,17 @@ void main() {
|
|||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
),
|
||||
),
|
||||
routes:
|
||||
routes ??
|
||||
RouteRepository(
|
||||
MemoryRouteGateway(),
|
||||
TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
),
|
||||
),
|
||||
routing: routing ?? FakeRoutingGateway(),
|
||||
recording: recording,
|
||||
health: health,
|
||||
loop: loop,
|
||||
|
|
@ -1808,6 +1827,200 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('routes (ADR 0030, D44)', () {
|
||||
late TrainKeys keys;
|
||||
late RouteRepository routes;
|
||||
late FakeRoutingGateway routing;
|
||||
late Directory folder;
|
||||
|
||||
setUp(() {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
routes = RouteRepository(MemoryRouteGateway(), keys);
|
||||
routing = FakeRoutingGateway();
|
||||
folder = Directory.systemTemp.createTempSync('train');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await routes.dispose();
|
||||
keys.dispose();
|
||||
folder.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
/// Home's *Routes*, on a phone at [at] (null: a device that can't tell).
|
||||
Future<void> openRoutes(
|
||||
WidgetTester tester, {
|
||||
({double lat, double lon})? at,
|
||||
}) async {
|
||||
await start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
routes: routes,
|
||||
routing: routing,
|
||||
recording: at == null
|
||||
? null
|
||||
: (
|
||||
platform: FakeRecorderPlatform()..at = at,
|
||||
file: File('${folder.path}/recording.txt'),
|
||||
straps: DeviceStore(File('${folder.path}/strap.json')),
|
||||
rowers: DeviceStore(File('${folder.path}/rower.json')),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Routes'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Taps the map [offset] from its centre.
|
||||
Future<void> tapMap(WidgetTester tester, Offset offset) async {
|
||||
await tester.tapAt(tester.getCenter(find.byType(FlutterMap)) + offset);
|
||||
// Past the wait for a second tap, which would zoom.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a route starts where the phone is, is planned by tapping the '
|
||||
'map, saved once named and listed with its distance and climb', (
|
||||
tester,
|
||||
) async {
|
||||
await openRoutes(tester, at: (lat: 48.137, lon: 11.575));
|
||||
expect(find.text('No routes yet'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(
|
||||
find.text(
|
||||
'Tap the map to add a waypoint; hold one and drag it to '
|
||||
'move it.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tapMap(tester, const Offset(60, 40));
|
||||
expect(
|
||||
routing.asked.single.waypoints.first,
|
||||
const LatLng(48.137, 11.575),
|
||||
);
|
||||
expect(routing.asked.single.profile, RouteProfile.run);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
expect(find.text('100 m up'), findsOneWidget);
|
||||
expect(find.text('0 m down'), findsOneWidget);
|
||||
expect(find.text('Elevation'), findsOneWidget);
|
||||
|
||||
final save = find.widgetWithText(TextButton, 'Save');
|
||||
expect(tester.widget<TextButton>(save).onPressed, isNull);
|
||||
await tester.enterText(
|
||||
find.widgetWithText(TextFormField, 'Name'),
|
||||
'To the park',
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Paved'),
|
||||
100,
|
||||
scrollable: find
|
||||
.descendant(
|
||||
of: find.byType(ListView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(find.text('Surfaces'), findsOneWidget);
|
||||
await tester.tap(save);
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('To the park'), findsOneWidget);
|
||||
expect(find.textContaining(' km · 100 m up'), findsOneWidget);
|
||||
final [route] =
|
||||
await tester.runAsync(routes.all) ?? const <StoredRoute>[];
|
||||
expect(route.content?.waypoints, hasLength(2));
|
||||
expect(route.content?.planned, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('the activity picks the profile, which plans the way again', (
|
||||
tester,
|
||||
) async {
|
||||
await openRoutes(tester);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
await tapMap(tester, Offset.zero);
|
||||
await tapMap(tester, const Offset(60, 40));
|
||||
expect(routing.asked.last.profile, RouteProfile.run);
|
||||
|
||||
await tester.tap(find.text('Any activity'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Cycling').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(routing.asked.last.profile, RouteProfile.trekkingBike);
|
||||
expect(find.text('Trekking bike'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a refusal says why; undo takes the last waypoint off', (
|
||||
tester,
|
||||
) async {
|
||||
routing.refusal = RoutingRefusal.noRoute;
|
||||
await openRoutes(tester);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
// A device that can't tell where it is starts with none.
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
await tapMap(tester, Offset.zero);
|
||||
await tapMap(tester, const Offset(60, 40));
|
||||
expect(find.textContaining('No way found there'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Take the last waypoint off'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('2'), findsNothing);
|
||||
expect(find.textContaining('No way found there'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('an instance that plans no routes says so', (tester) async {
|
||||
routing.isOffered = false;
|
||||
await openRoutes(tester);
|
||||
await tester.tap(find.text('New route'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("Your instance doesn't plan routes."), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a route is opened, deleted after asking and brought back', (
|
||||
tester,
|
||||
) async {
|
||||
final id = await routes.save(
|
||||
RouteContent(
|
||||
name: 'Isar',
|
||||
profile: RouteProfile.run,
|
||||
waypoints: const [LatLng(48.137, 11.575), LatLng(48.12, 11.58)],
|
||||
planned: straightRoute(const [
|
||||
LatLng(48.137, 11.575),
|
||||
LatLng(48.12, 11.58),
|
||||
]),
|
||||
),
|
||||
);
|
||||
await openRoutes(tester);
|
||||
await tester.tap(find.text('Isar'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Route'), findsOneWidget);
|
||||
expect(routing.asked, isEmpty);
|
||||
|
||||
await tester.tap(find.byTooltip('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Delete this route?'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Isar'), findsNothing);
|
||||
await tester.tap(find.text('Undo'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Isar'), findsOneWidget);
|
||||
expect((await tester.runAsync(routes.all))!.single.id, id);
|
||||
});
|
||||
});
|
||||
|
||||
group('workouts (ADR 0030, D24)', () {
|
||||
late MemoryWorkoutGateway rows;
|
||||
late TrainKeys keys;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -131,6 +131,13 @@ class FakeRecorderPlatform({
|
|||
@override
|
||||
Future<bool> isRecording() async => _file != null;
|
||||
|
||||
/// Where the phone is; null: no fix.
|
||||
({double lat, double lon})? at;
|
||||
|
||||
@override
|
||||
Future<({double lat, double lon})?> position() async =>
|
||||
allows.contains(RecorderPermission.location) ? at : null;
|
||||
|
||||
/// A recording that runs on, as when the app comes back to one.
|
||||
void runOn(String path) => _file = File(path);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:async';
|
||||
|
||||
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:latlong2/latlong.dart';
|
||||
|
||||
/// [RoutingGateway] planning straight lines between the waypoints, 100 m of
|
||||
/// climb a waypoint after the first; or refusing with [refusal], or holding
|
||||
/// its answers until [release] where [held].
|
||||
class FakeRoutingGateway({
|
||||
var bool isOffered = true,
|
||||
var RoutingRefusal? refusal,
|
||||
var bool held = false,
|
||||
}) implements RoutingGateway {
|
||||
/// What was asked for, in order.
|
||||
final asked = <({List<LatLng> waypoints, RouteProfile profile})>[];
|
||||
|
||||
final _held = <Completer<void>>[];
|
||||
|
||||
/// Answers every plan held so far.
|
||||
void release() {
|
||||
for (final answer in _held) {
|
||||
answer.complete();
|
||||
}
|
||||
_held.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> offered() async => isOffered;
|
||||
|
||||
@override
|
||||
Future<RouteGeometry> plan(
|
||||
List<LatLng> waypoints,
|
||||
RouteProfile profile,
|
||||
) async {
|
||||
asked.add((waypoints: [...waypoints], profile: profile));
|
||||
final refused = refusal;
|
||||
if (held) {
|
||||
final answer = Completer<void>();
|
||||
_held.add(answer);
|
||||
await answer.future;
|
||||
}
|
||||
if (refused != null) {
|
||||
throw RoutingRefused(refused);
|
||||
}
|
||||
return straightRoute(waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
/// The straight lines through [waypoints], as [FakeRoutingGateway] plans.
|
||||
RouteGeometry straightRoute(List<LatLng> waypoints) {
|
||||
const distance = Distance();
|
||||
var metres = 0.0;
|
||||
for (var i = 1; i < waypoints.length; i++) {
|
||||
metres += distance(waypoints[i - 1], waypoints[i]);
|
||||
}
|
||||
return RouteGeometry(
|
||||
points: waypoints,
|
||||
elevations: [for (var i = 0; i < waypoints.length; i++) 500.0 + 100 * i],
|
||||
distanceMetres: metres.round(),
|
||||
ascentMetres: 100 * (waypoints.length - 1),
|
||||
hints: [
|
||||
for (var i = 1; i < waypoints.length - 1; i++)
|
||||
TurnHint(index: i, kind: TurnKind.left, metres: 0),
|
||||
],
|
||||
stretches: [
|
||||
RouteStretch(metres: metres.round(), surface: 'asphalt'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/route_gateway.dart';
|
||||
|
||||
/// [RouteGateway] in memory, whose rows a test changes as an instance could.
|
||||
class MemoryRouteGateway() implements RouteGateway {
|
||||
final rows = <String, Envelope>{};
|
||||
final _remote = StreamController<void>.broadcast();
|
||||
|
||||
/// As a sync bringing others' changes.
|
||||
void bringRemoteChanges() => _remote.add(null);
|
||||
|
||||
@override
|
||||
Future<List<RouteRow>> routes() async => [
|
||||
for (final MapEntry(:key, :value) in rows.entries)
|
||||
(id: key, envelope: value),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> insert(RouteRow route) async => rows[route.id] = route.envelope;
|
||||
|
||||
@override
|
||||
Future<void> update(String id, Envelope envelope) async {
|
||||
if (rows.containsKey(id)) {
|
||||
rows[id] = envelope;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async => rows.remove(id);
|
||||
|
||||
@override
|
||||
Stream<void> get remoteChanges => _remote.stream;
|
||||
}
|
||||
Loading…
Reference in a new issue