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

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',
},
);
});
}