feat(train): routes planned through the instance's BRouter (ADR 0030, D44)

The routing endpoint plans a route through the waypoints for one of six
profiles, for the people signed in, at most 50 waypoints and 30 plans a
person a minute, nothing kept or logged; refused without BROUTER_URL.
BRouter's GeoJSON is read into points, elevations, climb and descent
above 3 m, turn hints and stretches by surface. Train's running profile
for BRouter, halkyon-run.brf, is a first guess not yet tried on BRouter.

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-28 23:37:47 +01:00
commit 781576726a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 2563 additions and 1 deletions

View file

@ -0,0 +1,147 @@
import 'package:clock/clock.dart';
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/routing/brouter.dart';
import 'package:halkyon_train_server/src/routing/routing.dart';
import 'package:serverpod/serverpod.dart';
import 'package:test/test.dart';
import '../../unit/routing/brouter_test.dart' show brouterRoute;
import '../test_tools/serverpod_test_tools.dart';
/// BRouter as the tests want it: what it answers, and what it was asked.
class _FakeBRouter implements RoutePlanner {
final asked = <(List<RoutePoint>, RouteProfile)>[];
Exception? failure;
@override
Future<Map<String, Object?>> route(
List<RoutePoint> waypoints,
RouteProfile profile,
) async {
asked.add((waypoints, profile));
if (failure case final failure?) {
throw failure;
}
return brouterRoute();
}
}
Matcher _refused(String reason) =>
throwsA(isA<RouteRefused>().having((r) => r.reason, 'reason', reason));
/// Routes planned by the instance's BRouter (ADR 0030, D11, D44): for the
/// people signed in, where the instance has one, within a limit a minute.
void main() {
withServerpod('Planning routes', (sessionBuilder, endpoints) {
late _FakeBRouter brouter;
final alice = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final bob = sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
const Uuid().v7(),
{},
),
);
final there = [
RoutePoint(lat: 52.52, lon: 13.40),
RoutePoint(lat: 52.53, lon: 13.41),
];
void useRouting(Routing routing) =>
Routing.use(sessionBuilder.build().serverpod, routing);
setUp(() {
brouter = _FakeBRouter();
useRouting(Routing(router: brouter));
});
test(
'plans a route for someone signed in, with the profile asked',
() async {
expect(await endpoints.routing.offered(alice), isTrue);
final route = await endpoints.routing.plan(
alice,
there,
RouteProfile.gravel,
);
expect(route.distanceMetres, 1234);
expect(brouter.asked.single.$2, RouteProfile.gravel);
},
);
test('plans nothing for someone not signed in', () async {
await expectLater(
endpoints.routing.plan(sessionBuilder, there, RouteProfile.run),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
await expectLater(
endpoints.routing.offered(sessionBuilder),
throwsA(isA<ServerpodUnauthenticatedException>()),
);
expect(brouter.asked, isEmpty);
});
test('without a BRouter it offers none, and refuses', () async {
useRouting(Routing());
expect(await endpoints.routing.offered(alice), isFalse);
await expectLater(
endpoints.routing.plan(alice, there, RouteProfile.run),
_refused('off'),
);
});
test('refuses fewer than two waypoints, too many, and points off the '
'earth, asking BRouter nothing', () async {
for (final waypoints in [
[there.first],
[for (var i = 0; i <= Routing.maxWaypoints; i++) there.first],
[there.first, RoutePoint(lat: 91, lon: 0)],
[there.first, RoutePoint(lat: 0, lon: 181)],
]) {
await expectLater(
endpoints.routing.plan(alice, waypoints, RouteProfile.run),
_refused('invalid'),
);
}
expect(brouter.asked, isEmpty);
});
test('refuses a person beyond the plans of a minute, not others, and '
'plans again a minute later', () async {
var now = DateTime.utc(2026, 10, 28, 12);
await withClock(Clock(() => now), () async {
for (var i = 0; i < Routing.perMinute; i++) {
await endpoints.routing.plan(alice, there, RouteProfile.run);
}
await expectLater(
endpoints.routing.plan(alice, there, RouteProfile.run),
_refused('tooOften'),
);
await endpoints.routing.plan(bob, there, RouteProfile.run);
now = now.add(const Duration(minutes: 1));
await endpoints.routing.plan(alice, there, RouteProfile.run);
});
});
test('says where BRouter found no route, or did not answer', () async {
brouter.failure = NoRoute();
await expectLater(
endpoints.routing.plan(alice, there, RouteProfile.run),
_refused('noRoute'),
);
brouter.failure = BRouterFailed('down');
await expectLater(
endpoints.routing.plan(alice, there, RouteProfile.run),
_refused('unreachable'),
);
});
});
}

View file

@ -20,6 +20,12 @@ import 'package:halkyon_train_server/src/generated/future_calls.dart'
as _i59xasdy;
import 'package:halkyon_train_server/src/generated/map/map_source.dart'
as _ip8bmyd2;
import 'package:halkyon_train_server/src/generated/routing/planned_route.dart'
as _ij0vvqxn;
import 'package:halkyon_train_server/src/generated/routing/route_point.dart'
as _is4pcfub;
import 'package:halkyon_train_server/src/generated/routing/route_profile.dart'
as _iex9meso;
import 'package:serverpod/serverpod.dart' as _is;
import 'package:serverpod_test/serverpod_test.dart' as _ist;
import 'package:halkyon_train_server/src/generated/protocol.dart';
@ -160,6 +166,8 @@ class TestEndpoints {
late final _FirmwareEndpoint firmware;
late final _MapEndpoint map;
late final _RoutingEndpoint routing;
}
class _InternalTestEndpoints extends TestEndpoints
@ -177,6 +185,10 @@ class _InternalTestEndpoints extends TestEndpoints
endpoints,
serializationManager,
);
routing = _RoutingEndpoint(
endpoints,
serializationManager,
);
}
}
@ -336,6 +348,80 @@ class _MapEndpoint {
}
}
class _RoutingEndpoint {
_RoutingEndpoint(
this._endpointDispatch,
this._serializationManager,
);
final _is.EndpointDispatch _endpointDispatch;
final _is.SerializationManager _serializationManager;
_ida.Future<bool> offered(_ist.TestSessionBuilder sessionBuilder) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'routing',
method: 'offered',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'routing',
methodName: 'offered',
parameters: _ist.testObjectToJson({}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<bool>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<_ij0vvqxn.PlannedRoute> plan(
_ist.TestSessionBuilder sessionBuilder,
List<_is4pcfub.RoutePoint> waypoints,
_iex9meso.RouteProfile profile,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'routing',
method: 'plan',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'routing',
methodName: 'plan',
parameters: _ist.testObjectToJson({
'waypoints': waypoints,
'profile': profile,
}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<_ij0vvqxn.PlannedRoute>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
}
class _PruningFutureCall {
Future<void> pruneAccounts(_ist.TestSessionBuilder sessionBuilder) async {
var _localUniqueSession =

View file

@ -0,0 +1,189 @@
import 'package:halkyon_train_server/src/generated/protocol.dart';
import 'package:halkyon_train_server/src/routing/brouter.dart';
import 'package:test/test.dart';
/// A route as BRouter's GeoJSON gives it (written from its `FormatJson` and
/// `Formatter`, read 28 Oct 2026): numbers in the properties as strings,
/// coordinates `[lon, lat, elevation]`, turn hints `[index, command, exit,
/// metres, angle]`, and its messages a table whose first row names the
/// columns.
Map<String, Object?> brouterRoute({
List<List<num>>? coordinates,
List<Object?>? voicehints,
}) => {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'properties': {
'creator': 'BRouter-1.7.8',
'name': 'brouter_trekking_0',
'track-length': '1234',
'filtered ascend': '9',
'plain-ascend': '12',
'total-time': '300',
'voicehints':
voicehints ??
[
[1, 2, 0, 400, -90],
[2, 13, 2, 200, 45],
],
'messages': [
[
'Longitude',
'Latitude',
'Elevation',
'Distance',
'CostPerKm',
'ElevCost',
'TurnCost',
'NodeCost',
'InitialCost',
'WayTags',
'NodeTags',
'Time',
'Energy',
],
[
'13404954',
'52520008',
'34',
'300',
'1000',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt',
'',
'60',
'0',
],
[
'13405954',
'52521008',
'40',
'200',
'1000',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt',
'',
'100',
'0',
],
[
'13406954',
'52522008',
'38',
'734',
'1300',
'0',
'0',
'0',
'0',
'highway=track tracktype=grade2 surface=gravel',
'',
'300',
'0',
],
],
},
'geometry': {
'type': 'LineString',
'coordinates':
coordinates ??
[
[13.404954, 52.520008, 34.0],
[13.405954, 52.521008, 40.25],
[13.406954, 52.522008, 38.0],
[13.407954, 52.523008, 30.5],
],
},
},
],
};
void main() {
test('a route reads with its points, elevations, length, hints and '
'stretches, those of one way joined', () {
final route = plannedRouteOf(brouterRoute());
expect(
(route.points.first.lat, route.points.first.lon),
(52.520008, 13.404954),
);
expect(route.points, hasLength(4));
expect(route.elevations, [34.0, 40.25, 38.0, 30.5]);
expect(route.distanceMetres, 1234);
expect(route.hints.map((h) => (h.index, h.command, h.exit)), [
(1, 2, 0),
(2, 13, 2),
]);
expect(route.hints.first.metres, 400);
expect(route.hints.first.angle, -90);
expect(
route.stretches.map((s) => (s.metres, s.surface, s.highway)),
[(500, 'asphalt', 'residential'), (734, 'gravel', 'track')],
);
});
test("its climb and descent count only what passes 3 m, as a workout's", () {
final route = plannedRouteOf(brouterRoute());
// Up 6.25 from 34; down to 38 is within 3 m of 40.25, to 30.5 is not.
expect(route.ascentMetres, 6);
expect(route.descentMetres, 10);
});
test('without elevations it has none, nor a climb', () {
final route = plannedRouteOf(
brouterRoute(
coordinates: [
[13.40, 52.52],
[13.41, 52.53],
],
),
);
expect(route.elevations, isEmpty);
expect((route.ascentMetres, route.descentMetres), (0, 0));
});
test('a hint it cannot read, or one off the route, is left out', () {
final route = plannedRouteOf(
brouterRoute(
voicehints: [
[1, 5, 0, 100, 90],
[9, 2, 0, 100, -90],
['x'],
],
),
);
expect(route.hints.map((h) => h.command), [5]);
});
test('an answer without a route is a failure', () {
expect(
() => plannedRouteOf({'type': 'FeatureCollection', 'features': []}),
throwsA(isA<BRouterFailed>()),
);
});
test("Train's profiles are BRouter's, running Train's own", () {
expect(
{for (final profile in RouteProfile.values) brouterProfileOf(profile)},
{
'halkyon-run',
'hiking-mountain',
'fastbike',
'trekking',
'gravel',
'mtb',
},
);
});
}