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:
parent
8325d2a08b
commit
781576726a
2 changed files with 2563 additions and 1 deletions
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue