mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(tours): route paddle tours on the water, shaping points and auto-loaded regions
Paddle tours are planned on the water by TREK's own router: a water network per paddle region, built from the same Geofabrik extract as the obstacles and searched in a worker thread of the server (no sidecar). Rivers, canals and lakes (direct or along the shore), portages round weirs, locks by policy, whitewater by boat, gaps marked. Found portages and locks become points of their own; an unplannable leg says why and offers a way out. - Shaping points: a click on the line sets a point that steers the route without being a stop (hikes and paddle tours); recompute a single leg. - Lake style direct/shore, suggested from the wind forecast. - Regions load by themselves where a paddler plans (admin setting: automatic, on request, off; size cap and a daily count), with requests for the admins, a notification, and the load followed on the leg. - Portage from the last point, portages follow footways via the walking router (or stay straight), "put in here" on slipways and put-ins. - MCP plan_paddle_route with the REST route's rules.
This commit is contained in:
@@ -109,7 +109,7 @@ async function build() {
|
||||
.overrideProvider(GeofabrikClient)
|
||||
.useValue(geofabrik)
|
||||
.overrideProvider(DataPathsService)
|
||||
.useValue({ tmpDir: dir })
|
||||
.useValue({ tmpDir: dir, dataDir: dir })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Paddle routing e2e (PAD-11): `POST /api/trips/:tripId/paddle/route` through
|
||||
* the real AddonGuard, JwtAuthGuard and TripAccessGuard, the Zod pipe and the
|
||||
* response contract, over the real service, repository and router on a
|
||||
* migrated temp SQLite db. The region's network is built from an extract the
|
||||
* test draws (tests/helpers/water-world.ts) and handed over as the import does.
|
||||
*/
|
||||
import { PaddleRegions } from '../../src/db/entities/PaddleRegions.entity';
|
||||
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../src/nest/app-config/data-paths.service';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { importExtract } from '../../src/nest/paddle-obstacles/paddle-import';
|
||||
import { PaddleRoutingModule } from '../../src/nest/paddle-routing/paddle-routing.module';
|
||||
import { PaddleRoutingService } from '../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { insertRow } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember, makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { WaterWorld } from '../helpers/water-world';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { paddleRouteResponseSchema } from '@trek/shared';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
|
||||
const OWNER = 1;
|
||||
const MEMBER = 2;
|
||||
const STRANGER = 3;
|
||||
let tripId: number;
|
||||
let orm: TestOrm;
|
||||
let server: Server;
|
||||
let dir: string;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
/** The Regen, 6 km from Regenstauf south, with the Lindenmühle weir across it and no portage mapped. */
|
||||
const regen = new WaterWorld({ lat: 49.07, lng: 12.12 });
|
||||
const river = regen.line(
|
||||
[
|
||||
[0, 6000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
regen.tag(river.refs[30] as number, { waterway: 'weir', name: 'Lindenmühle' });
|
||||
// A mill canal 1.5 km to the east, so the region spans land too.
|
||||
regen.line(
|
||||
[
|
||||
[1500, 6000],
|
||||
[1500, 0],
|
||||
],
|
||||
{ waterway: 'canal', name: 'Mühlkanal' },
|
||||
);
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
AppConfigModule,
|
||||
PaddleRoutingModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(DataPathsService)
|
||||
.useValue({ dataDir: dir, tmpDir: dir })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const route = (body: unknown, userId?: number, trip = tripId) => {
|
||||
const req = request(server)
|
||||
.post(`/api/trips/${trip}/paddle/route`)
|
||||
.send(body as object);
|
||||
return userId === undefined ? req : req.set({ Cookie: sessionCookie(userId) });
|
||||
};
|
||||
const leg = { from: regen.at(20, 5500), to: regen.at(-30, 500) };
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-paddle-routing-e2e-'));
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: OWNER, email: 'owner@example.test' });
|
||||
await makeUser(orm, { id: MEMBER, email: 'member@example.test' });
|
||||
await makeUser(orm, { id: STRANGER, email: 'stranger@example.test' });
|
||||
tripId = (await makeTrip(orm, OWNER)).id;
|
||||
await addTripMember(orm, tripId, MEMBER);
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
|
||||
// The region's read, as the paddle obstacles import hands its network over.
|
||||
const extract = join(dir, 'oberpfalz.osm.pbf');
|
||||
await writeFile(extract, regen.pbf());
|
||||
const { graph } = await importExtract(extract, 'oberpfalz', () => undefined);
|
||||
if (!graph) throw new Error('no network');
|
||||
await insertRow(orm, PaddleRegions, { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready' });
|
||||
const routing = app.get(PaddleRoutingService);
|
||||
const file = await routing.writeGraphFile('oberpfalz', graph);
|
||||
await withRequestContext(app.get(MikroORM), () => routing.recordGraph('oberpfalz', file, graph));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
db.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('POST /api/trips/:tripId/paddle/route', () => {
|
||||
it('PADROUTE-E2E-001: 404 while Tours is off; 401 without a session', async () => {
|
||||
await setAddonEnabled(orm, 'tours', false);
|
||||
const off = await route({ legs: [leg] }, OWNER);
|
||||
expect(off.status).toBe(404);
|
||||
expect(off.body).toEqual({ error: 'Tours addon is not enabled' });
|
||||
await setAddonEnabled(orm, 'tours', true);
|
||||
const anonymous = await route({ legs: [leg] });
|
||||
expect(anonymous.status).toBe(401);
|
||||
});
|
||||
|
||||
it('PADROUTE-E2E-002: a trip the caller is not on answers 404, as one that does not exist', async () => {
|
||||
expect((await route({ legs: [leg] }, STRANGER)).status).toBe(404);
|
||||
expect((await route({ legs: [leg] }, OWNER, 999_999)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('PADROUTE-E2E-003: refuses a request off the contract with 400', async () => {
|
||||
// One after another: supertest binds the unstarted server per request, and requests at once reset each other.
|
||||
const answers = [
|
||||
await route({}, OWNER),
|
||||
await route({ legs: [] }, OWNER),
|
||||
await route({ legs: [leg], boat: 'yacht' }, OWNER),
|
||||
await route({ legs: [{ from: { lat: 95, lng: 0 }, to: leg.to }] }, OWNER),
|
||||
];
|
||||
expect(answers.map((res) => res.status)).toEqual([400, 400, 400, 400]);
|
||||
});
|
||||
|
||||
it('PADROUTE-E2E-004: owner and member get the leg planned on the water, carried round the weir, on the contract', async () => {
|
||||
const answers = [
|
||||
await route({ legs: [leg], boat: 'canoe' }, OWNER),
|
||||
await route({ legs: [leg], boat: 'canoe' }, MEMBER),
|
||||
];
|
||||
for (const res of answers) {
|
||||
expect(res.status).toBe(200);
|
||||
const answer = paddleRouteResponseSchema.parse(res.body);
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok' });
|
||||
const planned = answer.legs[0];
|
||||
if (planned?.status !== 'ok') throw new Error('not planned');
|
||||
expect(planned.distance_m).toBeGreaterThan(4900);
|
||||
expect(planned.segments.find((segment) => segment.kind === 'portage')).toMatchObject({
|
||||
name: 'Lindenmühle',
|
||||
source: 'inferred',
|
||||
});
|
||||
expect(answer.graph_rev).toMatch(/^[0-9a-f]{16}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('PADROUTE-E2E-005: answers every leg on its own: one off the water, one where no region has data', async () => {
|
||||
const res = await route(
|
||||
{
|
||||
legs: [
|
||||
{ from: regen.at(0, 5000), to: regen.at(900, 3000) },
|
||||
{ from: { lat: 53.4, lng: 12.7 }, to: { lat: 53.5, lng: 12.7 } },
|
||||
],
|
||||
},
|
||||
OWNER,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.legs).toEqual([
|
||||
{ status: 'failed', code: 'not_on_water', at: regen.at(900, 3000) },
|
||||
{ status: 'failed', code: 'no_region', at: { lat: 53.4, lng: 12.7 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -714,6 +714,42 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
expect(readAgain(detail.body)).toEqual(readAgain(res.body));
|
||||
});
|
||||
|
||||
it('saves how a paddle tour crosses lakes and its shaping points, and reads them back (PAD-11, P2A-03)', async () => {
|
||||
const body = carried({ paddle_lake_style: 'shore' });
|
||||
(body.waypoints as Array<Record<string, unknown>>)[1] = {
|
||||
lat: 47.01,
|
||||
lng: 11.01,
|
||||
role: 'via',
|
||||
sequence: 1,
|
||||
shaping: true,
|
||||
};
|
||||
const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(body);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.tour).toMatchObject({ paddle_lake_style: 'shore' });
|
||||
expect(res.body.waypoints[1]).toMatchObject({ shaping: true });
|
||||
expect(res.body.waypoints[0].shaping).toBeUndefined();
|
||||
const detail = await request(server)
|
||||
.get(`/api/trips/5/tours/${res.body.tour.place_id}`)
|
||||
.set('Cookie', sessionCookie(OWNER));
|
||||
expect(detail.body.tour.paddle_lake_style).toBe('shore');
|
||||
expect(detail.body.waypoints[1].shaping).toBe(true);
|
||||
|
||||
const onHike = carried({
|
||||
tour_type: 'hike',
|
||||
max_hiking_difficulty: 2,
|
||||
boat_type: null,
|
||||
water_context: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: 'direct',
|
||||
waypoints: [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.02, lng: 11.02, role: 'end', sequence: 1 },
|
||||
],
|
||||
});
|
||||
const refused = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(onHike);
|
||||
expect(refused.status).toBe(400);
|
||||
});
|
||||
|
||||
it('400 for a portage, a lock or a carrying speed on a hike; nothing written', async () => {
|
||||
const onHike = carried({ tour_type: 'hike', max_hiking_difficulty: 2, boat_type: null, water_context: null });
|
||||
const res = await request(server).post('/api/trips/5/tours').set('Cookie', sessionCookie(OWNER)).send(onHike);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* A small OpenStreetMap PBF writer for the paddle tests: just enough of the
|
||||
* format (blob framing, raw and zlib blobs, a header block, string tables,
|
||||
* plain and dense nodes, ways) to build an extract in the test itself, so
|
||||
* plain and dense nodes, ways, relations) to build an extract in the test itself, so
|
||||
* no binary fixture is checked in. The field numbers are the ones of
|
||||
* https://wiki.openstreetmap.org/wiki/PBF_Format.
|
||||
*/
|
||||
@@ -20,6 +20,19 @@ export interface FixtureWay {
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FixtureRelation {
|
||||
id: number;
|
||||
members: Array<{ type: 'node' | 'way' | 'relation'; ref: number; role?: string }>;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
type BlockContent = {
|
||||
dense?: FixtureNode[];
|
||||
nodes?: FixtureNode[];
|
||||
ways?: FixtureWay[];
|
||||
relations?: FixtureRelation[];
|
||||
};
|
||||
|
||||
export function varint(value: number): Buffer {
|
||||
const bytes: number[] = [];
|
||||
let rest = value;
|
||||
@@ -54,8 +67,8 @@ function deltas(values: number[]): number[] {
|
||||
return values.map((value, index) => value - (values[index - 1] ?? 0));
|
||||
}
|
||||
|
||||
/** One data block: a string table and one group of dense nodes, plain nodes and ways. */
|
||||
export function dataBlock(content: { dense?: FixtureNode[]; nodes?: FixtureNode[]; ways?: FixtureWay[] }): Buffer {
|
||||
/** One data block: a string table and one group of dense nodes, plain nodes, ways and relations. */
|
||||
export function dataBlock(content: BlockContent): Buffer {
|
||||
const strings = [''];
|
||||
const index = (text: string): number => {
|
||||
const at = strings.indexOf(text);
|
||||
@@ -118,6 +131,35 @@ export function dataBlock(content: { dense?: FixtureNode[]; nodes?: FixtureNode[
|
||||
groups.push(bytesField(3, body));
|
||||
}
|
||||
|
||||
for (const relation of content.relations ?? []) {
|
||||
const tags = Object.entries(relation.tags ?? {});
|
||||
const body = Buffer.concat([
|
||||
varintField(1, relation.id),
|
||||
packed(
|
||||
2,
|
||||
tags.map(([k]) => index(k)),
|
||||
false,
|
||||
),
|
||||
packed(
|
||||
3,
|
||||
tags.map(([, v]) => index(v)),
|
||||
false,
|
||||
),
|
||||
packed(
|
||||
8,
|
||||
relation.members.map((member) => index(member.role ?? '')),
|
||||
false,
|
||||
),
|
||||
packed(9, deltas(relation.members.map((member) => member.ref)), true),
|
||||
packed(
|
||||
10,
|
||||
relation.members.map((member) => ['node', 'way', 'relation'].indexOf(member.type)),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
groups.push(bytesField(4, body));
|
||||
}
|
||||
|
||||
const table = Buffer.concat(strings.map((text) => bytesField(1, Buffer.from(text, 'utf8'))));
|
||||
return Buffer.concat([bytesField(1, table), ...groups.map((group) => bytesField(2, group))]);
|
||||
}
|
||||
@@ -146,7 +188,7 @@ export function framedBlob(
|
||||
|
||||
/** A whole extract: the header block, then one data block per entry. */
|
||||
export function pbfFile(
|
||||
blocks: Array<{ dense?: FixtureNode[]; nodes?: FixtureNode[]; ways?: FixtureWay[] }>,
|
||||
blocks: BlockContent[],
|
||||
options: { timestamp?: number; compression?: 'zlib' | 'raw' } = {},
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* A small piece of the world for the water routing tests (PAD-11), drawn in
|
||||
* metres east and north of an origin and written as an OpenStreetMap extract
|
||||
* with the PBF fixture writer: rivers, weirs, portages, lakes, as many as a
|
||||
* test needs, so no binary map is checked in.
|
||||
*/
|
||||
import { pbfFile, type FixtureNode, type FixtureRelation, type FixtureWay } from './osm-pbf-fixture';
|
||||
|
||||
/** Metres per degree of latitude, as the router's own geometry takes it. */
|
||||
const M_PER_DEG = 111_195;
|
||||
|
||||
export type Metres = [east: number, north: number];
|
||||
|
||||
export class WaterWorld {
|
||||
private readonly nodes: FixtureNode[] = [];
|
||||
private readonly ways: FixtureWay[] = [];
|
||||
private readonly relations: FixtureRelation[] = [];
|
||||
private nextNode = 1;
|
||||
private nextWay = 1;
|
||||
|
||||
constructor(private readonly origin = { lat: 49, lng: 12 }) {}
|
||||
|
||||
/** The position `east` and `north` metres from the origin. */
|
||||
at(east: number, north: number): { lat: number; lng: number } {
|
||||
const cos = Math.cos((this.origin.lat * Math.PI) / 180);
|
||||
return { lat: this.origin.lat + north / M_PER_DEG, lng: this.origin.lng + east / (M_PER_DEG * cos) };
|
||||
}
|
||||
|
||||
/** A node there, tagged or not; its id. */
|
||||
node([east, north]: Metres, tags?: Record<string, string>): number {
|
||||
const { lat, lng } = this.at(east, north);
|
||||
const id = this.nextNode++;
|
||||
this.nodes.push({ id, lat, lon: lng, ...(tags ? { tags } : {}) });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Tags an existing node, as OSM tags a weir on a node of its river. */
|
||||
tag(id: number, tags: Record<string, string>): void {
|
||||
const node = this.nodes.find((candidate) => candidate.id === id);
|
||||
if (!node) throw new Error(`no node ${id}`);
|
||||
node.tags = { ...node.tags, ...tags };
|
||||
}
|
||||
|
||||
/** A way through existing nodes; its id. */
|
||||
way(refs: number[], tags: Record<string, string> = {}): number {
|
||||
const id = this.nextWay++;
|
||||
this.ways.push({ id, refs, tags });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A way through new nodes at these spots, every `step` metres along straight runs; its id and nodes. */
|
||||
line(points: Metres[], tags: Record<string, string>, step = 100): { id: number; refs: number[] } {
|
||||
const refs: number[] = [];
|
||||
points.forEach((point, index) => {
|
||||
const next = points[index + 1];
|
||||
refs.push(this.node(point));
|
||||
if (!next) return;
|
||||
const length = Math.hypot(next[0] - point[0], next[1] - point[1]);
|
||||
const parts = Math.max(1, Math.round(length / step));
|
||||
for (let i = 1; i < parts; i++) {
|
||||
const t = i / parts;
|
||||
refs.push(this.node([point[0] + (next[0] - point[0]) * t, point[1] + (next[1] - point[1]) * t]));
|
||||
}
|
||||
});
|
||||
return { id: this.way(refs, tags), refs };
|
||||
}
|
||||
|
||||
/** A closed way round these corners (the first is repeated at the end); its id. */
|
||||
ring(corners: Metres[], tags: Record<string, string> = {}, step = 100): number {
|
||||
const { refs } = this.line([...corners, corners[0] as Metres], {}, step);
|
||||
// The closing node is the first one, as OSM draws a closed way.
|
||||
refs[refs.length - 1] = refs[0] as number;
|
||||
const way = this.ways.at(-1) as FixtureWay;
|
||||
way.refs = refs;
|
||||
way.tags = tags;
|
||||
return way.id;
|
||||
}
|
||||
|
||||
relation(members: FixtureRelation['members'], tags: Record<string, string>): number {
|
||||
const id = this.relations.length + 1;
|
||||
this.relations.push({ id, members, tags });
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The extract: nodes, ways and relations in blocks of their own, as Geofabrik writes them. */
|
||||
pbf(timestamp = 1_760_000_000): Buffer {
|
||||
return pbfFile([{ dense: this.nodes }, { ways: this.ways }, { relations: this.relations }], { timestamp });
|
||||
}
|
||||
}
|
||||
|
||||
/** The length of a line of [lat, lng] in metres, flat earth as the tests' scale allows. */
|
||||
export function lineMetres(line: ReadonlyArray<readonly [number, number]>): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const [aLat, aLng] = line[i - 1] as [number, number];
|
||||
const [bLat, bLng] = line[i] as [number, number];
|
||||
const cos = Math.cos((aLat * Math.PI) / 180);
|
||||
total += Math.hypot((bLat - aLat) * M_PER_DEG, (bLng - aLng) * M_PER_DEG * cos);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -44,8 +44,8 @@ describe('ALL_ENTITIES', () => {
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 140 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(140);
|
||||
it('ENT-003: exactly 141 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(141);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at.
|
||||
// + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id,
|
||||
// added_at (a link and its members are always written whole).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 482;
|
||||
// + paddle_water_graphs: file, rev, bytes, built_at, stats and its bounding box (a network is only ever written
|
||||
// whole, from one build of its region).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 491;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||
|
||||
@@ -55,6 +55,10 @@ const UNNUMBERED = [
|
||||
'Migration20200101044100_paddle_obstacles_from_openstreetmap',
|
||||
'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions',
|
||||
'Migration20200101044300_a_trip_links_a_journal',
|
||||
'Migration20200101044400_a_paddle_region_builds_its_water_network',
|
||||
'Migration20200101044500_a_tour_waypoint_can_shape_the_route',
|
||||
'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes',
|
||||
'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -126,4 +126,16 @@ describe('TourWaypointsRepository', () => {
|
||||
t.clear();
|
||||
expect((await repo.listForPlace(place.id))[0]).toEqual({ lat: 47, lng: 11, role: 'start', sequence: 0 });
|
||||
});
|
||||
|
||||
it('TWREPO-007: a shaping point is stored as such and read back; a stop carries no flag (P2A-03)', async () => {
|
||||
const { place } = tourInTrip();
|
||||
await repo.insertForPlace(place.id, [
|
||||
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
|
||||
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1, shaping: true },
|
||||
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
|
||||
]);
|
||||
const points = await repo.listForPlace(place.id);
|
||||
expect(points[1]).toEqual({ lat: 47.05, lng: 11.05, role: 'via', sequence: 1, shaping: true });
|
||||
expect(points[0]).not.toHaveProperty('shaping');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('ToursRepository reads', () => {
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
},
|
||||
@@ -123,6 +124,7 @@ describe('ToursRepository reads', () => {
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
},
|
||||
@@ -206,6 +208,7 @@ describe('ToursRepository writes', () => {
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
has_recorded_track: 0,
|
||||
});
|
||||
@@ -218,6 +221,7 @@ describe('ToursRepository writes', () => {
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
};
|
||||
|
||||
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
|
||||
@@ -283,6 +287,7 @@ describe('ToursRepository writes', () => {
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
track_marks: null,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ToursBoatType, ToursWaterContext } from '../../../src/db/entities/Tours.entity';
|
||||
import { paddleBoatTypeSchema, paddleWaterContextSchema } from '@trek/shared';
|
||||
import { ToursBoatType, ToursPaddleLakeStyle, ToursWaterContext } from '../../../src/db/entities/Tours.entity';
|
||||
import { paddleBoatTypeSchema, paddleLakeStyleSchema, paddleWaterContextSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* The boat and water columns hold a CHECK list (migration 043900) that the
|
||||
* The boat, water and lake style columns hold a CHECK list (migrations 043900
|
||||
* and 044600) that the
|
||||
* entity carries as its enum, and the contract holds its own list. A value
|
||||
* one side knows and the other refuses would be a tour the planner can save
|
||||
* and the database cannot store, or the other way round.
|
||||
@@ -17,4 +18,8 @@ describe('paddle columns match the contract', () => {
|
||||
it('knows the same waters as the paddle contract', () => {
|
||||
expect(Object.values(ToursWaterContext).sort()).toEqual([...paddleWaterContextSchema.options].sort());
|
||||
});
|
||||
|
||||
it('knows the same lake styles as the paddle contract (PAD-11)', () => {
|
||||
expect(Object.values(ToursPaddleLakeStyle).sort()).toEqual([...paddleLakeStyleSchema.options].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Where a region's read runs (PAD-11): the node positions it keeps, the read
|
||||
* on the calling thread, the thread's own script, and the runner that starts
|
||||
* a thread per read, here with small threads written by the test that report
|
||||
* progress, hand back a network, fail, die or are called off.
|
||||
*/
|
||||
import {
|
||||
importRunner,
|
||||
InlineImportRunner,
|
||||
WorkerImportRunner,
|
||||
} from '../../../../src/nest/paddle-obstacles/import-runner';
|
||||
import { NodeStore } from '../../../../src/nest/paddle-obstacles/node-store';
|
||||
import { WaterWorld } from '../../../helpers/water-world';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const thread = vi.hoisted(() => ({
|
||||
postMessage: vi.fn(),
|
||||
workerData: { path: '', regionId: 'worker-test' },
|
||||
}));
|
||||
vi.mock('node:worker_threads', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:worker_threads')>()),
|
||||
parentPort: { postMessage: thread.postMessage },
|
||||
get workerData() {
|
||||
return thread.workerData;
|
||||
},
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
let extract: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-import-runner-'));
|
||||
const world = new WaterWorld();
|
||||
world.line(
|
||||
[
|
||||
[0, 2000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
extract = join(dir, 'region.osm.pbf');
|
||||
await writeFile(extract, world.pbf());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('NodeStore', () => {
|
||||
it('PIMP-001: keeps the positions of the nodes asked for, once each, at OSM resolution', () => {
|
||||
const store = new NodeStore();
|
||||
for (let id = 3000; id > 0; id -= 1) store.want(id);
|
||||
store.wantAll([5, 5, 7]);
|
||||
expect(store.size).toBe(3003);
|
||||
store.seal();
|
||||
store.seal();
|
||||
expect(store.size).toBe(3000);
|
||||
store.offer(5, 49.123456789, 12.000000049);
|
||||
store.offer(9999, 1, 1);
|
||||
expect(store.get(5)).toEqual([49.1234568, 12]);
|
||||
expect(store.get(6)).toBeNull();
|
||||
expect(store.get(9999)).toBeNull();
|
||||
expect(() => store.want(1)).toThrow('NodeStore is sealed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the read on the calling thread', () => {
|
||||
it('PIMP-002: reads the extract into obstacles and a network, and gives up a read called off', async () => {
|
||||
const runner = new InlineImportRunner();
|
||||
const phases: string[] = [];
|
||||
const outcome = await runner.run(extract, 'regen', (phase) => phases.push(phase), new AbortController().signal);
|
||||
expect(outcome.graph?.stats.water_km).toBeCloseTo(2, 1);
|
||||
expect(phases).toContain('build');
|
||||
|
||||
const calledOff = new AbortController();
|
||||
calledOff.abort(new Error('region removed'));
|
||||
await expect(runner.run(extract, 'regen', () => undefined, calledOff.signal)).rejects.toThrow('region removed');
|
||||
});
|
||||
|
||||
it('PIMP-003: picks the inline read where the build shipped no thread script', () => {
|
||||
expect(importRunner()).toBeInstanceOf(InlineImportRunner);
|
||||
});
|
||||
|
||||
it('PIMP-004: the thread script reads its region, reports progress and hands the outcome back', async () => {
|
||||
thread.workerData = { path: extract, regionId: 'worker-test' };
|
||||
await import('../../../../src/nest/paddle-obstacles/paddle-import.worker');
|
||||
await vi.waitFor(() =>
|
||||
expect(thread.postMessage).toHaveBeenCalledWith({
|
||||
outcome: expect.objectContaining({ graph: expect.anything() }),
|
||||
}),
|
||||
);
|
||||
expect(thread.postMessage).toHaveBeenCalledWith({ progress: { phase: 'build', fraction: null } });
|
||||
});
|
||||
|
||||
it('PIMP-005: the thread script reports a read that failed', async () => {
|
||||
vi.resetModules();
|
||||
thread.postMessage.mockClear();
|
||||
thread.workerData = { path: join(dir, 'missing.osm.pbf'), regionId: 'worker-test' };
|
||||
await import('../../../../src/nest/paddle-obstacles/paddle-import.worker');
|
||||
await vi.waitFor(() =>
|
||||
expect(thread.postMessage).toHaveBeenCalledWith({ error: expect.stringContaining('missing.osm.pbf') }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** A thread script the runner can start: plain JS, so it runs without the server's loader. */
|
||||
async function script(name: string, body: string): Promise<string> {
|
||||
const path = join(dir, `${name}.cjs`);
|
||||
await writeFile(path, `const { parentPort, workerData } = require('node:worker_threads');\n${body}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('WorkerImportRunner', () => {
|
||||
it('PIMP-006: passes on the progress and hands back the outcome, its network bytes as bytes', async () => {
|
||||
const entry = await script(
|
||||
'reads',
|
||||
`parentPort.postMessage({ progress: { phase: 'read', fraction: 0.5 } });
|
||||
parentPort.postMessage({ outcome: { timestamp: 1, features: [], graph: { bytes: Buffer.from([1, 2, 3]), rev: 'r', stats: {}, box: {} } } });`,
|
||||
);
|
||||
const progress = vi.fn();
|
||||
const outcome = await new WorkerImportRunner(entry).run(extract, 'regen', progress, new AbortController().signal);
|
||||
expect(progress).toHaveBeenCalledWith('read', 0.5);
|
||||
expect(outcome.graph?.bytes).toBeInstanceOf(Uint8Array);
|
||||
expect([...(outcome.graph?.bytes ?? [])]).toEqual([1, 2, 3]);
|
||||
|
||||
const dry = await script(
|
||||
'dry',
|
||||
`parentPort.postMessage({ outcome: { timestamp: null, features: [], graph: null } });`,
|
||||
);
|
||||
await expect(
|
||||
new WorkerImportRunner(dry).run(extract, 'dry', () => undefined, new AbortController().signal),
|
||||
).resolves.toEqual({
|
||||
timestamp: null,
|
||||
features: [],
|
||||
graph: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('PIMP-007: a read the thread reports failed, a thread that throws and one that ends early all reject', async () => {
|
||||
const fails = await script('fails', `parentPort.postMessage({ error: 'not a PBF file' });`);
|
||||
const silent = await script('silent', `parentPort.postMessage({});`);
|
||||
const throws = await script('throws', `throw new Error('out of memory');`);
|
||||
const ends = await script('ends', `process.exit(7);`);
|
||||
const run = (entry: string) =>
|
||||
new WorkerImportRunner(entry).run(extract, 'x', () => undefined, new AbortController().signal);
|
||||
await expect(run(fails)).rejects.toThrow('not a PBF file');
|
||||
await expect(run(silent)).rejects.toThrow('the read failed');
|
||||
await expect(run(throws)).rejects.toThrow('out of memory');
|
||||
await expect(run(ends)).rejects.toThrow('the read ended early (7)');
|
||||
});
|
||||
|
||||
it('PIMP-008: a read called off ends its thread at once, and one called off before it starts never starts', async () => {
|
||||
const slow = await script('slow', `setTimeout(() => parentPort.postMessage({ error: 'too late' }), 60000);`);
|
||||
const runner = new WorkerImportRunner(slow);
|
||||
const controller = new AbortController();
|
||||
const reading = runner.run(extract, 'x', () => undefined, controller.signal);
|
||||
controller.abort(new Error('removed'));
|
||||
await expect(reading).rejects.toThrow('removed');
|
||||
|
||||
const notError = new AbortController();
|
||||
const pending = runner.run(extract, 'x', () => undefined, notError.signal);
|
||||
notError.abort('gone');
|
||||
await expect(pending).rejects.toThrow('gone');
|
||||
|
||||
const before = new AbortController();
|
||||
before.abort(new Error('already off'));
|
||||
await expect(runner.run(extract, 'x', () => undefined, before.signal)).rejects.toThrow('already off');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* The PBF reader over extracts the test writes itself (tests/helpers/osm-pbf-fixture.ts):
|
||||
* blob framing across read chunks, raw and zlib blobs, the header timestamp,
|
||||
* plain and dense nodes, ways, and the ways a broken file is refused.
|
||||
* plain and dense nodes, ways, relations, node positions alone (PAD-11), and
|
||||
* the ways a broken file is refused.
|
||||
*/
|
||||
import {
|
||||
readDataBlock,
|
||||
readHeaderTimestamp,
|
||||
readPbfFile,
|
||||
type OsmNode,
|
||||
type OsmRelation,
|
||||
type OsmWay,
|
||||
} from '../../../../src/nest/paddle-obstacles/osm-pbf.reader';
|
||||
import {
|
||||
@@ -204,3 +206,57 @@ describe('readDataBlock and readHeaderTimestamp', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relations and node positions (PAD-11)', () => {
|
||||
it('PBF-013: reads a relation with its members, their types and roles, and skips a field it does not know', () => {
|
||||
const block = dataBlock({
|
||||
relations: [
|
||||
{
|
||||
id: 7,
|
||||
members: [
|
||||
{ type: 'way', ref: 100, role: 'outer' },
|
||||
{ type: 'way', ref: 90, role: 'inner' },
|
||||
{ type: 'node', ref: 5 },
|
||||
{ type: 'relation', ref: 3, role: 'subarea' },
|
||||
],
|
||||
tags: { type: 'multipolygon', natural: 'water', name: 'Müritz' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const relations: OsmRelation[] = [];
|
||||
readDataBlock(block, { relation: (relation) => relations.push(relation) });
|
||||
expect(relations).toEqual([
|
||||
{
|
||||
id: 7,
|
||||
members: [
|
||||
{ type: 'way', ref: 100, role: 'outer' },
|
||||
{ type: 'way', ref: 90, role: 'inner' },
|
||||
{ type: 'node', ref: 5, role: '' },
|
||||
{ type: 'relation', ref: 3, role: 'subarea' },
|
||||
],
|
||||
tags: { type: 'multipolygon', natural: 'water', name: 'Müritz' },
|
||||
},
|
||||
]);
|
||||
// A relation with a field the format added later still reads.
|
||||
const unknownField = Buffer.concat([varintField(1, 9), varintField(15, 1)]);
|
||||
const group = bytesField(4, unknownField);
|
||||
const table = bytesField(1, bytesField(1, Buffer.from('')));
|
||||
const read: OsmRelation[] = [];
|
||||
readDataBlock(Buffer.concat([table, bytesField(2, group)]), { relation: (relation) => read.push(relation) });
|
||||
expect(read).toEqual([{ id: 9, members: [], tags: {} }]);
|
||||
});
|
||||
|
||||
it('PBF-014: hands a position visitor the plain and the dense nodes, and nothing else', () => {
|
||||
const block = dataBlock({
|
||||
dense: [{ id: 1, lat: 49.1, lon: 12.1, tags: { waterway: 'weir' } }],
|
||||
nodes: [{ id: 2, lat: 49.2, lon: 12.2 }],
|
||||
ways: [{ id: 3, refs: [1, 2] }],
|
||||
});
|
||||
const seen: Array<[number, number, number]> = [];
|
||||
readDataBlock(block, { coord: (id, lat, lon) => seen.push([id, lat, lon]) });
|
||||
expect(seen.sort((a, b) => a[0] - b[0])).toEqual([
|
||||
[1, expect.closeTo(49.1, 7), expect.closeTo(12.1, 7)],
|
||||
[2, expect.closeTo(49.2, 7), expect.closeTo(12.2, 7)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
* The two-pass extraction of PAD-05A over extracts written in the test: the
|
||||
* objects kept with their own geometry (a weir across a river stays a line),
|
||||
* their tag hash, and the parts of one lock grouped while separate locks
|
||||
* stay apart.
|
||||
* stay apart; and the water sink the same passes feed (PAD-11).
|
||||
*/
|
||||
import {
|
||||
extractPaddleFeatures,
|
||||
groupLockParts,
|
||||
type ExtractedFeature,
|
||||
type ExtractSink,
|
||||
} from '../../../../src/nest/paddle-obstacles/paddle-extract';
|
||||
import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import';
|
||||
import { decodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph';
|
||||
import { pbfFile, type FixtureNode } from '../../../helpers/osm-pbf-fixture';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
let dir: string;
|
||||
let n = 0;
|
||||
@@ -186,3 +189,69 @@ describe('groupLockParts', () => {
|
||||
expect(new Set([a.group_key, b.group_key, c.group_key])).toEqual(new Set(['lock:node/1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the water sink (PAD-11)', () => {
|
||||
/** A sink that records what it hears and asks for the member ways of one relation. */
|
||||
function recorder(neededWays: number[]) {
|
||||
const heard: string[] = [];
|
||||
let want: (id: number) => void = () => undefined;
|
||||
const sink: ExtractSink = {
|
||||
neededWays: new Set(neededWays),
|
||||
node: (id) => heard.push(`node/${id}`),
|
||||
way: (id, refs) => {
|
||||
heard.push(`way/${id}`);
|
||||
for (const ref of refs) want(ref);
|
||||
},
|
||||
relation: (id) => heard.push(`relation/${id}`),
|
||||
memberWay: (id) => heard.push(`member/${id}`),
|
||||
};
|
||||
return {
|
||||
heard,
|
||||
sinkFor: (asked: (id: number) => void) => {
|
||||
want = asked;
|
||||
return sink;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('PEXT-008: hears every tagged node, way and relation, reads the member ways in a pass of their own, and knows their positions', async () => {
|
||||
const path = join(dir, `region-${n++}.osm.pbf`);
|
||||
await writeFile(
|
||||
path,
|
||||
pbfFile([
|
||||
{ dense: [...weirNodes, { id: 4, lat: 49.2, lon: 12.2, tags: { natural: 'water' } }] },
|
||||
{
|
||||
ways: [
|
||||
{ id: 10, refs: [1, 2, 3], tags: { waterway: 'river' } },
|
||||
{ id: 11, refs: [1, 3] },
|
||||
],
|
||||
},
|
||||
{ relations: [{ id: 5, members: [{ type: 'way', ref: 11, role: 'outer' }], tags: { type: 'multipolygon' } }] },
|
||||
]),
|
||||
);
|
||||
const { heard, sinkFor } = recorder([11]);
|
||||
const progress: number[] = [];
|
||||
const { coordOf } = await extractPaddleFeatures(path, (fraction) => progress.push(fraction), sinkFor);
|
||||
expect(heard).toEqual(['node/4', 'way/10', 'way/11', 'relation/5', 'member/10', 'member/11']);
|
||||
expect(coordOf(2)).toEqual([49.1236, 12.1309]);
|
||||
// Three passes: the elements, the member ways, the positions.
|
||||
expect(progress.some((fraction) => Math.abs(fraction - 2 / 3) < 1e-9)).toBe(true);
|
||||
expect(progress.at(-1)).toBe(1);
|
||||
});
|
||||
|
||||
it('PEXT-009: without member ways to read the pass is left out; an extract without a date builds a network without one', async () => {
|
||||
const path = join(dir, `region-${n++}.osm.pbf`);
|
||||
await writeFile(
|
||||
path,
|
||||
pbfFile([{ dense: weirNodes }, { ways: [{ id: 10, refs: [1, 2, 3], tags: { waterway: 'river' } }] }]),
|
||||
);
|
||||
const { heard, sinkFor } = recorder([]);
|
||||
const read = vi.fn();
|
||||
await extractPaddleFeatures(path, read, sinkFor);
|
||||
expect(heard).toEqual(['way/10']);
|
||||
|
||||
const { graph, timestamp } = await importExtract(path, 'undated', () => undefined);
|
||||
expect(timestamp).toBeNull();
|
||||
expect(decodeWaterGraph(Buffer.from(graph?.bytes ?? [])).sourceTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
PaddleRegionsController,
|
||||
} from '../../../../src/nest/paddle-obstacles/paddle-obstacles.controller';
|
||||
import type { PaddleObstaclesService } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.service';
|
||||
import type { PaddleRegionLoadService } from '../../../../src/nest/paddle-obstacles/paddle-region-load.service';
|
||||
import type { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
@@ -23,7 +24,9 @@ import {
|
||||
paddleObstaclesResponseSchema,
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionLoadResponseSchema,
|
||||
paddleRegionSchema,
|
||||
paddleRegionSettingsSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegion,
|
||||
} from '@trek/shared';
|
||||
@@ -35,11 +38,13 @@ const region: PaddleRegion = {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'queued',
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
};
|
||||
|
||||
describe('PaddleObstaclesController', () => {
|
||||
@@ -56,7 +61,10 @@ describe('PaddleObstaclesController', () => {
|
||||
it('PADCTL-002: hands the box on and passes a refusal through as it is', async () => {
|
||||
const answer = { features: [], coverage: { state: 'none' as const, regions: [] }, truncated: false };
|
||||
const inBox = vi.fn<PaddleObstaclesService['inBox']>().mockResolvedValue(answer);
|
||||
const controller = new PaddleObstaclesController({ inBox } as unknown as PaddleObstaclesService);
|
||||
const controller = new PaddleObstaclesController(
|
||||
{ inBox } as unknown as PaddleObstaclesService,
|
||||
{} as PaddleRegionLoadService,
|
||||
);
|
||||
|
||||
expect(await controller.inBox({ bbox: '49,12,49.1,12.1' })).toBe(answer);
|
||||
expect(inBox).toHaveBeenCalledWith('49,12,49.1,12.1');
|
||||
@@ -67,6 +75,26 @@ describe('PaddleObstaclesController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleObstaclesController.loadRegions (PAD-11C)', () => {
|
||||
it('PADCTL-007: anyone signed in with Tours on, POST with 200, the places and the user handed on', async () => {
|
||||
const handler = PaddleObstaclesController.prototype.loadRegions;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('regions/load');
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRegionLoadResponseSchema);
|
||||
const answer = [{ state: 'ready' as const, region: { id: 'oberpfalz', name: 'Oberpfalz' } }];
|
||||
const load = { load: vi.fn().mockResolvedValue(answer) };
|
||||
const controller = new PaddleObstaclesController(
|
||||
{} as PaddleObstaclesService,
|
||||
load as unknown as PaddleRegionLoadService,
|
||||
);
|
||||
const user = { id: 3, role: 'user' } as User;
|
||||
const body = { points: [{ lat: 49.3, lng: 12.2 }], retry: false };
|
||||
expect(await controller.loadRegions(user, body)).toEqual({ points: answer });
|
||||
expect(load.load).toHaveBeenCalledWith(user, body);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsController', () => {
|
||||
it('PADCTL-003: admin only, with the routes, status codes and contracts of the admin surface', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PaddleRegionsController)).toBe('api/admin/paddle-regions');
|
||||
@@ -95,15 +123,17 @@ describe('PaddleRegionsController', () => {
|
||||
refresh: vi.fn().mockResolvedValue(region),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const load = { pending: vi.fn().mockResolvedValue([]) };
|
||||
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new PaddleRegionsController(
|
||||
service as unknown as PaddleRegionsService,
|
||||
load as unknown as PaddleRegionLoadService,
|
||||
audit as unknown as AuditService,
|
||||
);
|
||||
const admin = { id: 2 } as User;
|
||||
const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request;
|
||||
|
||||
expect(await controller.list()).toEqual({ regions: [region] });
|
||||
expect(await controller.list()).toEqual({ regions: [region], requests: [] });
|
||||
expect(await controller.catalog({ q: 'ober' })).toEqual({
|
||||
regions: [{ id: 'oberpfalz', name: 'Oberpfalz', path: ['Europe'] }],
|
||||
});
|
||||
@@ -127,12 +157,51 @@ describe('PaddleRegionsController', () => {
|
||||
it('PADCTL-005: a refused change leaves no audit row', async () => {
|
||||
const refusal = new DomainError(404, 'Region not found');
|
||||
const service = { remove: vi.fn().mockRejectedValue(refusal) };
|
||||
const load = {};
|
||||
const audit = { writeAudit: vi.fn() };
|
||||
const controller = new PaddleRegionsController(
|
||||
service as unknown as PaddleRegionsService,
|
||||
load as unknown as PaddleRegionLoadService,
|
||||
audit as unknown as AuditService,
|
||||
);
|
||||
await expect(controller.remove({ id: 2 } as User, { id: 'bayern' }, {} as Request)).rejects.toBe(refusal);
|
||||
expect(audit.writeAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PADCTL-006: the setting and declining a request, each with its route, contract and audit row (PAD-11C)', async () => {
|
||||
const p = PaddleRegionsController.prototype;
|
||||
const routes = [
|
||||
[p.settings, RequestMethod.GET, 'settings', undefined, paddleRegionSettingsSchema],
|
||||
[p.updateSettings, RequestMethod.PUT, 'settings', undefined, paddleRegionSettingsSchema],
|
||||
[p.decline, RequestMethod.POST, 'decline', 200, successResponseSchema],
|
||||
] as const;
|
||||
for (const [handler, method, path, code, schema] of routes) {
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path);
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(code);
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(schema);
|
||||
}
|
||||
const settings = { mode: 'ask' as const, max_mb: 250 };
|
||||
const load = {
|
||||
settings: vi.fn().mockResolvedValue(settings),
|
||||
updateSettings: vi.fn().mockResolvedValue(settings),
|
||||
decline: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const audit = { writeAudit: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new PaddleRegionsController(
|
||||
{} as PaddleRegionsService,
|
||||
load as unknown as PaddleRegionLoadService,
|
||||
audit as unknown as AuditService,
|
||||
);
|
||||
const req = { ip: '203.0.113.5', headers: {}, socket: { remoteAddress: '203.0.113.5' } } as unknown as Request;
|
||||
expect(await controller.settings()).toBe(settings);
|
||||
expect(await controller.updateSettings({ id: 2 } as User, settings, req)).toBe(settings);
|
||||
expect(load.updateSettings).toHaveBeenCalledWith(settings);
|
||||
expect(await controller.decline({ id: 2 } as User, { id: 'norway' }, req)).toEqual({ success: true });
|
||||
expect(load.decline).toHaveBeenCalledWith('norway');
|
||||
expect(audit.writeAudit.mock.calls.map(([entry]) => [entry.action, entry.details])).toEqual([
|
||||
['admin.paddle_region_settings', settings],
|
||||
['admin.paddle_region_decline', { region: 'norway' }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PaddleFeatures } from '../../../../src/db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity';
|
||||
import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module';
|
||||
import { DemoModule } from '../../../../src/nest/common/demo.module';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module';
|
||||
import {
|
||||
@@ -85,7 +86,7 @@ const weir = (regionId: string, ref: string, lat: number, lng: number) => ({
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
await t?.close();
|
||||
t = await createTestModule({ db, imports: [AppConfigModule, PaddleObstaclesModule] });
|
||||
t = await createTestModule({ db, imports: [AppConfigModule, DemoModule, PaddleObstaclesModule] });
|
||||
service = t.get(PaddleObstaclesService);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* PAD-11C, PaddleRegionLoadService over a snapshot db with Geofabrik faked:
|
||||
* a place no region covers gets the smallest region around it loaded, asked
|
||||
* for or refused, as the admin's setting, the region's size and the day's
|
||||
* count say; a region TREK holds already answers how far its read is; the
|
||||
* admins hear of a request once.
|
||||
*/
|
||||
import { PaddleRegionRequests } from '../../../../src/db/entities/PaddleRegionRequests.entity';
|
||||
import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity';
|
||||
import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
||||
import { DemoModule } from '../../../../src/nest/common/demo.module';
|
||||
import { DemoService } from '../../../../src/nest/common/demo.service';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { NotificationsService } from '../../../../src/nest/notifications/notifications.service';
|
||||
import { GeofabrikClient, type GeofabrikRegion } from '../../../../src/nest/paddle-obstacles/geofabrik.client';
|
||||
import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module';
|
||||
import { PaddleRegionLoadService } from '../../../../src/nest/paddle-obstacles/paddle-region-load.service';
|
||||
import { PaddleRegionsService } from '../../../../src/nest/paddle-obstacles/paddle-regions.service';
|
||||
import { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import type { User } from '../../../../src/types';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows';
|
||||
import { makeUser } from '../../../helpers/factories/users';
|
||||
import { pbfFile } from '../../../helpers/osm-pbf-fixture';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { PADDLE_AUTO_REGIONS_PER_DAY, paddleRegionLoadStateSchema } from '@trek/shared';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
||||
...log,
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
|
||||
const box = (west: number, south: number, east: number, north: number) => ({
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [
|
||||
[
|
||||
[west, south],
|
||||
[east, south],
|
||||
[east, north],
|
||||
[west, north],
|
||||
[west, south],
|
||||
] as [number, number][],
|
||||
],
|
||||
});
|
||||
const region = (id: string, name: string, boundary: GeofabrikRegion['boundary']): GeofabrikRegion => ({
|
||||
id,
|
||||
name,
|
||||
parent: null,
|
||||
pbfUrl: `https://download.geofabrik.de/${id}-latest.osm.pbf`,
|
||||
boundary,
|
||||
});
|
||||
const catalogue = new Map<string, GeofabrikRegion>(
|
||||
[
|
||||
region('germany', 'Germany', box(5, 47, 16, 55)),
|
||||
region('bayern', 'Bayern', box(9, 47, 14, 51)),
|
||||
region('oberpfalz', 'Oberpfalz', box(11.5, 48.9, 13, 50)),
|
||||
region('mecklenburg-vorpommern', 'Mecklenburg-Vorpommern', box(10.5, 53, 14.5, 54.7)),
|
||||
region('nowhere', 'Nowhere', null),
|
||||
].map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
const REGENSBURG = { lat: 49.3, lng: 12.2 };
|
||||
const MUERITZ = { lat: 53.45, lng: 12.7 };
|
||||
const ATLANTIC = { lat: 0, lng: -30 };
|
||||
|
||||
const extract = pbfFile([{ dense: [{ id: 1, lat: 49.3, lon: 12.2, tags: { canoe: 'put_in' } }] }], {
|
||||
timestamp: Date.UTC(2026, 9, 1) / 1000,
|
||||
});
|
||||
|
||||
const regions = vi.fn<() => Promise<Map<string, GeofabrikRegion>>>();
|
||||
const download = vi.fn<(url: string, target: string) => Promise<void>>();
|
||||
const sizeOf = vi.fn<(url: string) => Promise<number | null>>();
|
||||
|
||||
let dir: string;
|
||||
let t: TestModule | undefined;
|
||||
let load: PaddleRegionLoadService;
|
||||
let send: ReturnType<typeof vi.spyOn>;
|
||||
let paddler: User;
|
||||
let admin: User;
|
||||
const orm = () => {
|
||||
if (!t) throw new Error('No container booted');
|
||||
return t.get(MikroORM);
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-paddle-load-'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
regions.mockReset().mockResolvedValue(catalogue);
|
||||
download.mockReset().mockImplementation((_url, target) => writeFile(target, extract));
|
||||
sizeOf.mockReset().mockResolvedValue(80 * 1024 * 1024);
|
||||
await t?.close();
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [AppConfigModule, DemoModule, PaddleObstaclesModule],
|
||||
overrides: [
|
||||
{ provide: GeofabrikClient, useValue: { regions, download, sizeOf } },
|
||||
{ provide: DataPathsService, useValue: { tmpDir: dir, dataDir: dir } },
|
||||
],
|
||||
});
|
||||
load = t.get(PaddleRegionLoadService);
|
||||
send = vi.spyOn(t.get(NotificationsService), 'send').mockResolvedValue(undefined as never);
|
||||
paddler = (await makeUser(orm(), { username: 'elmo' })).user as unknown as User;
|
||||
admin = (await makeUser(orm(), { username: 'maurice', role: 'admin' })).user as unknown as User;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await t?.get(PaddleRegionsService).idle();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t?.close();
|
||||
db.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ask = async (user: User, points = [REGENSBURG], retry = false) => {
|
||||
const answers = await load.load(user, { points, retry });
|
||||
for (const answer of answers) expect(paddleRegionLoadStateSchema.safeParse(answer).success).toBe(true);
|
||||
return answers;
|
||||
};
|
||||
|
||||
describe('PaddleRegionLoadService', () => {
|
||||
it('PLOAD-001: loads the smallest region around a place by itself, and follows it on the next ask', async () => {
|
||||
const [first] = await ask(paddler);
|
||||
expect(first).toMatchObject({ state: 'loading', region: { id: 'oberpfalz', name: 'Oberpfalz' } });
|
||||
expect(await findRow(orm(), PaddleRegions, { id: 'oberpfalz' })).toMatchObject({ origin: 'auto' });
|
||||
expect(sizeOf).toHaveBeenCalledWith('https://download.geofabrik.de/oberpfalz-latest.osm.pbf');
|
||||
await ask(paddler);
|
||||
expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(1);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PLOAD-002: answers two places in one region with one decision', async () => {
|
||||
const answers = await ask(paddler, [REGENSBURG, { lat: 49.31, lng: 12.21 }]);
|
||||
expect(answers.map((answer) => answer.region?.id)).toEqual(['oberpfalz', 'oberpfalz']);
|
||||
expect(sizeOf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('PLOAD-003: sends a region over the size limit to the admins as a request, and tells them once', async () => {
|
||||
sizeOf.mockResolvedValue(900 * 1024 * 1024);
|
||||
const [answer] = await ask(paddler);
|
||||
expect(answer).toEqual({ state: 'requested', region: { id: 'oberpfalz', name: 'Oberpfalz' }, reason: 'too_large' });
|
||||
await ask(paddler);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
event: 'paddle_region_requested',
|
||||
scope: 'admin',
|
||||
params: { region: 'Oberpfalz', actor: 'elmo' },
|
||||
}),
|
||||
);
|
||||
expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(0);
|
||||
expect(await load.pending()).toEqual([
|
||||
expect.objectContaining({ id: 'oberpfalz', reason: 'too_large', requested_by: 'elmo', declined: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('PLOAD-004: holds TREK to a few regions a day and asks the admins for the next', async () => {
|
||||
for (let i = 0; i < PADDLE_AUTO_REGIONS_PER_DAY; i += 1) {
|
||||
await insertRow(orm(), PaddleRegions, { id: `auto-${i}`, name: `Auto ${i}`, origin: 'auto', status: 'ready' });
|
||||
}
|
||||
const [answer] = await ask(paddler);
|
||||
expect(answer).toMatchObject({ state: 'requested', reason: 'limit' });
|
||||
});
|
||||
|
||||
it('PLOAD-005: in the ask mode files every region, while an admin planning loads it at once, whatever its size', async () => {
|
||||
await load.updateSettings({ mode: 'ask', max_mb: 500 });
|
||||
expect((await ask(paddler))[0]).toMatchObject({ state: 'requested', reason: 'ask' });
|
||||
sizeOf.mockResolvedValue(3000 * 1024 * 1024);
|
||||
expect((await ask(admin))[0]).toMatchObject({ state: 'loading', region: { id: 'oberpfalz' } });
|
||||
expect(await findRow(orm(), PaddleRegions, { id: 'oberpfalz' })).toMatchObject({ origin: 'admin' });
|
||||
expect(await load.pending()).toEqual([]);
|
||||
});
|
||||
|
||||
it('PLOAD-006: switched off, or for a demo account, loads nothing and asks nobody', async () => {
|
||||
await load.updateSettings({ mode: 'off', max_mb: 500 });
|
||||
expect((await ask(paddler))[0]).toEqual({
|
||||
state: 'unavailable',
|
||||
region: { id: 'oberpfalz', name: 'Oberpfalz' },
|
||||
reason: 'off',
|
||||
});
|
||||
await load.updateSettings({ mode: 'auto', max_mb: 500 });
|
||||
vi.spyOn(t?.get(DemoService) as DemoService, 'isDemoUserId').mockResolvedValue(true);
|
||||
expect((await ask(paddler))[0]).toMatchObject({ state: 'unavailable', reason: 'off' });
|
||||
expect(await findRows(orm(), PaddleRegions, {})).toHaveLength(0);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PLOAD-007: a declined region is not asked for again; an admin adding it settles the request', async () => {
|
||||
sizeOf.mockResolvedValue(900 * 1024 * 1024);
|
||||
await ask(paddler);
|
||||
await load.decline('oberpfalz');
|
||||
expect((await ask(paddler))[0]).toEqual({ state: 'declined', region: { id: 'oberpfalz', name: 'Oberpfalz' } });
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await t?.get(PaddleRegionsService).add('oberpfalz');
|
||||
expect(await findRows(orm(), PaddleRegionRequests, {})).toHaveLength(0);
|
||||
await expect(load.decline('oberpfalz')).rejects.toBeInstanceOf(DomainError);
|
||||
});
|
||||
|
||||
it('PLOAD-008: says ready where a network covers the place, and nothing is loaded', async () => {
|
||||
await insertRow(orm(), PaddleRegions, { id: 'oberpfalz', name: 'Oberpfalz', status: 'ready' });
|
||||
vi.spyOn(t?.get(PaddleRoutingService) as PaddleRoutingService, 'regionsCovering').mockResolvedValue(['oberpfalz']);
|
||||
expect((await ask(paddler))[0]).toEqual({ state: 'ready', region: { id: 'oberpfalz', name: 'Oberpfalz' } });
|
||||
expect(sizeOf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PLOAD-009: a region held answers its read: failed, read again on retry, built anew when it has no network, else no water', async () => {
|
||||
const held = { id: 'oberpfalz', name: 'Oberpfalz', min_lat: 48.9, min_lng: 11.5, max_lat: 50, max_lng: 13 };
|
||||
await insertRow(orm(), PaddleRegions, { ...held, status: 'failed' });
|
||||
expect((await ask(paddler))[0]).toMatchObject({ state: 'failed' });
|
||||
expect((await ask(paddler, [REGENSBURG], true))[0]).toMatchObject({ state: 'loading' });
|
||||
await t?.get(PaddleRegionsService).idle();
|
||||
await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { status: 'ready' });
|
||||
const routing = t?.get(PaddleRoutingService) as PaddleRoutingService;
|
||||
vi.spyOn(routing, 'networks').mockResolvedValue(new Map());
|
||||
expect((await ask(paddler))[0]).toMatchObject({ state: 'loading' });
|
||||
await t?.get(PaddleRegionsService).idle();
|
||||
await updateRows(orm(), PaddleRegions, { id: 'oberpfalz' }, { status: 'ready' });
|
||||
vi.spyOn(routing, 'networks').mockResolvedValue(new Map([['oberpfalz', {} as never]]));
|
||||
expect((await ask(paddler))[0]).toEqual({ state: 'no_water', region: { id: 'oberpfalz', name: 'Oberpfalz' } });
|
||||
});
|
||||
|
||||
it('PLOAD-010: says where nothing can be had: no extract around the place, or the list out of reach', async () => {
|
||||
expect((await ask(paddler, [ATLANTIC]))[0]).toEqual({ state: 'unavailable', region: null, reason: 'no_extract' });
|
||||
regions.mockRejectedValue(new Error('timeout'));
|
||||
expect((await ask(paddler, [MUERITZ]))[0]).toEqual({
|
||||
state: 'unavailable',
|
||||
region: null,
|
||||
reason: 'catalog_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('PLOAD-011: the setting starts automatic at 500 MB and falls back there from a broken row', async () => {
|
||||
expect(await load.settings()).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
expect(await load.updateSettings({ mode: 'ask', max_mb: 1000 })).toEqual({ mode: 'ask', max_mb: 1000 });
|
||||
const { AppSettings } = await import('../../../../src/db/entities/AppSettings.entity');
|
||||
await updateRows(orm(), AppSettings, { key: 'paddle_auto_load' }, { value: 'sometimes' });
|
||||
await updateRows(orm(), AppSettings, { key: 'paddle_auto_max_mb' }, { value: '99999' });
|
||||
expect(await load.settings()).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
});
|
||||
|
||||
it('PLOAD-012: a size Geofabrik does not announce counts as small enough; the download still caps it', async () => {
|
||||
sizeOf.mockResolvedValue(null);
|
||||
expect((await ask(paddler, [MUERITZ]))[0]).toMatchObject({
|
||||
state: 'loading',
|
||||
region: { id: 'mecklenburg-vorpommern' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,15 @@
|
||||
* PaddleRegionsService over a snapshot db with Geofabrik faked: a region
|
||||
* added, read in the background from an extract the test writes, its objects
|
||||
* stored; the queue in order; a failed read that keeps the previous data; a
|
||||
* region removed while it is read; the monthly and the boot requeue.
|
||||
* region removed while it is read; the monthly and the boot requeue; the
|
||||
* water network the same read builds for routing (PAD-11).
|
||||
*/
|
||||
import { PaddleFeatures } from '../../../../src/db/entities/PaddleFeatures.entity';
|
||||
import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity';
|
||||
import type { PaddleRegionsRepository } from '../../../../src/db/repositories/PaddleRegions.repository';
|
||||
import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
||||
import { DemoModule } from '../../../../src/nest/common/demo.module';
|
||||
import { DomainError } from '../../../../src/nest/common/domain-error';
|
||||
import { GeofabrikClient, type GeofabrikRegion } from '../../../../src/nest/paddle-obstacles/geofabrik.client';
|
||||
import { PaddleObstaclesModule } from '../../../../src/nest/paddle-obstacles/paddle-obstacles.module';
|
||||
@@ -23,6 +25,7 @@ import { countRows, deleteRows, findRow, findRows, updateRows } from '../../../h
|
||||
import { pbfFile } from '../../../helpers/osm-pbf-fixture';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { WaterWorld } from '../../../helpers/water-world';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { getRepositoryToken } from '@mikro-orm/nestjs';
|
||||
import { paddleRegionSchema } from '@trek/shared';
|
||||
@@ -112,10 +115,10 @@ beforeEach(async () => {
|
||||
await t?.close();
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [AppConfigModule, PaddleObstaclesModule],
|
||||
imports: [AppConfigModule, DemoModule, PaddleObstaclesModule],
|
||||
overrides: [
|
||||
{ provide: GeofabrikClient, useValue: { regions, download } },
|
||||
{ provide: DataPathsService, useValue: { tmpDir: dir } },
|
||||
{ provide: DataPathsService, useValue: { tmpDir: dir, dataDir: dir } },
|
||||
],
|
||||
});
|
||||
service = t.get(PaddleRegionsService);
|
||||
@@ -407,6 +410,57 @@ describe('PaddleRegionsService: restart and shutdown', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsService and the water network (PAD-11)', () => {
|
||||
/** The Regen past Regenstauf, 3 km of river. */
|
||||
const regen = new WaterWorld({ lat: 49.12, lng: 12.13 });
|
||||
regen.line(
|
||||
[
|
||||
[0, 3000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
const graphs = () => readdir(join(dir, 'paddle', 'graphs')).catch(() => [] as string[]);
|
||||
|
||||
it('PREG-018: a read builds the network beside the objects, lists its counts, and a read again replaces its file', async () => {
|
||||
download.mockImplementation((_url, target) => writeFile(target, regen.pbf()));
|
||||
await service.add('oberpfalz');
|
||||
await service.idle();
|
||||
const [listed] = await service.list();
|
||||
expect(paddleRegionSchema.parse(listed).network).toMatchObject({ water_km: expect.closeTo(3, 1), lakes: 0 });
|
||||
const first = await graphs();
|
||||
expect(first).toHaveLength(1);
|
||||
|
||||
regen.line(
|
||||
[
|
||||
[500, 3000],
|
||||
[500, 0],
|
||||
],
|
||||
{ waterway: 'canal', name: 'Kanal' },
|
||||
);
|
||||
await service.refresh('oberpfalz');
|
||||
await service.idle();
|
||||
const second = await graphs();
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second).not.toEqual(first);
|
||||
});
|
||||
|
||||
it('PREG-019: a region removed while its network is written keeps no file; a region without water lists no network', async () => {
|
||||
download.mockImplementationOnce(async (_url, target) => {
|
||||
await writeFile(target, regen.pbf());
|
||||
await deleteRows(orm(), PaddleRegions, { id: 'oberpfalz' });
|
||||
});
|
||||
await service.add('oberpfalz');
|
||||
await service.idle();
|
||||
expect(await graphs()).toEqual([]);
|
||||
|
||||
await service.add('oberpfalz');
|
||||
await service.idle();
|
||||
const [listed] = await service.list();
|
||||
expect(listed?.network).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsService.catalog', () => {
|
||||
it('PREG-013: finds regions by name or id, names starting with the query first, with the regions they lie in', async () => {
|
||||
expect(await service.catalog('ober')).toEqual([
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* The REST route and the MCP tool of PAD-11 over a stub service: the route's
|
||||
* guards, status and contract, the tool's gate, scope and input, and that
|
||||
* both hand the same request to the same service. The answers through the
|
||||
* real guards are pinned in the e2e suite.
|
||||
*/
|
||||
import { ADDON_IDS } from '../../../../src/addons';
|
||||
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
|
||||
import { AddonGuard } from '../../../../src/nest/addons/addon.guard';
|
||||
import type { AddonsService } from '../../../../src/nest/addons/addons.service';
|
||||
import { REQUIRE_ADDON } from '../../../../src/nest/addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
|
||||
import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
|
||||
import { PaddleRoutingController } from '../../../../src/nest/paddle-routing/paddle-routing.controller';
|
||||
import { PaddleRoutingMcp } from '../../../../src/nest/paddle-routing/paddle-routing.mcp';
|
||||
import type { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard';
|
||||
import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
|
||||
import { RequestMethod } from '@nestjs/common';
|
||||
import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { paddleRouteRequestSchema, paddleRouteResponseSchema, type PaddleRouteResponse } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
const A = { lat: 49.1283, lng: 12.1303 };
|
||||
const B = { lat: 49.022, lng: 12.111 };
|
||||
|
||||
const ANSWER: PaddleRouteResponse = {
|
||||
graph_rev: 'r1',
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[49.07, 12.12],
|
||||
[B.lat, B.lng],
|
||||
],
|
||||
segments: [
|
||||
{ kind: 'water', from: 0, to: 1, name: 'Regen', source: null, osm_ref: null, grade: null },
|
||||
{ kind: 'portage', from: 1, to: 2, name: 'Wehr', source: 'tagged', osm_ref: 'way/1', grade: null },
|
||||
],
|
||||
distance_m: 15600,
|
||||
snapped_m: { from: 12, to: 0 },
|
||||
},
|
||||
{ status: 'failed', code: 'no_region', at: B },
|
||||
],
|
||||
};
|
||||
|
||||
function service() {
|
||||
return { route: vi.fn<PaddleRoutingService['route']>().mockResolvedValue(ANSWER) };
|
||||
}
|
||||
|
||||
describe('PaddleRoutingController', () => {
|
||||
it('PADROUTE-CTL-001: POST api/trips/:tripId/paddle/route sits behind Tours, the session and the trip, answers 200 on the contract', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, PaddleRoutingController)).toBe('api/trips/:tripId/paddle');
|
||||
expect(Reflect.getMetadata(GUARDS_METADATA, PaddleRoutingController)).toEqual([
|
||||
AddonGuard,
|
||||
JwtAuthGuard,
|
||||
TripAccessGuard,
|
||||
]);
|
||||
expect(Reflect.getMetadata(REQUIRE_ADDON, PaddleRoutingController)).toEqual({ addonId: 'tours', label: 'Tours' });
|
||||
const handler = PaddleRoutingController.prototype.route;
|
||||
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
|
||||
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('route');
|
||||
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handler)).toBe(200);
|
||||
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(paddleRouteResponseSchema);
|
||||
});
|
||||
|
||||
it('PADROUTE-CTL-002: hands the parsed request to the service and answers what it planned', async () => {
|
||||
const routing = service();
|
||||
const controller = new PaddleRoutingController(routing as unknown as PaddleRoutingService);
|
||||
const body = paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }], boat: 'sup' });
|
||||
await expect(controller.route(body)).resolves.toBe(ANSWER);
|
||||
expect(routing.route).toHaveBeenCalledWith(body);
|
||||
});
|
||||
});
|
||||
|
||||
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
|
||||
|
||||
function toolOptions(): ToolOptions {
|
||||
const entry = getEntry(PaddleRoutingMcp as unknown as ClassRef, 'planPaddleRoute');
|
||||
if (!entry || entry.kind !== 'tool') throw new Error('plan_paddle_route is not registered');
|
||||
return entry.options;
|
||||
}
|
||||
|
||||
function makeMcp(accessible: boolean) {
|
||||
const routing = service();
|
||||
const access = { findAccessible: vi.fn().mockResolvedValue(accessible ? { id: 3 } : null) };
|
||||
const mcp = new PaddleRoutingMcp(
|
||||
routing as unknown as PaddleRoutingService,
|
||||
access as unknown as TripAccessService,
|
||||
{} as AddonsService,
|
||||
);
|
||||
return { mcp, routing, access };
|
||||
}
|
||||
|
||||
describe('PaddleRoutingMcp', () => {
|
||||
it('PADROUTE-MCP-001: plan_paddle_route is read only, gated on Tours and scoped to reading trips', () => {
|
||||
const options = toolOptions();
|
||||
expect(options.name).toBe('plan_paddle_route');
|
||||
expect(options.annotations?.readOnlyHint).toBe(true);
|
||||
expect(options.access).toEqual({ group: 'trips', mode: 'read' });
|
||||
const isAddonEnabled = vi.fn((id: string) => id === ADDON_IDS.TOURS);
|
||||
const self = { addons: { isAddonEnabled } } as unknown as PaddleRoutingMcp;
|
||||
expect(options.when?.(ctx, self)).toBe(true);
|
||||
expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.TOURS);
|
||||
});
|
||||
|
||||
it('PADROUTE-MCP-002: takes the REST contract plus the trip, with the same defaults and limits', () => {
|
||||
const input = z.object(toolOptions().inputSchema as z.ZodRawShape);
|
||||
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }] }).success).toBe(true);
|
||||
expect(input.safeParse({ tripId: 3, legs: [] }).success).toBe(false);
|
||||
expect(input.safeParse({ tripId: 3, legs: [{ from: A, to: B }], lake_style: 'zigzag' }).success).toBe(false);
|
||||
expect(input.safeParse({ legs: [{ from: A, to: B }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('PADROUTE-MCP-003: plans for a trip the caller sees, the same request as REST, and answers each leg without its points', async () => {
|
||||
const { mcp, routing, access } = makeMcp(true);
|
||||
const result = (await mcp.planPaddleRoute({ tripId: 3, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult;
|
||||
expect(access.findAccessible).toHaveBeenCalledWith(3, 7);
|
||||
expect(routing.route).toHaveBeenCalledWith(paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }] }));
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
graph_rev: 'r1',
|
||||
legs: [
|
||||
{
|
||||
status: 'ok',
|
||||
distance_m: 15600,
|
||||
snapped_m: { from: 12, to: 0 },
|
||||
points: 3,
|
||||
segments: [
|
||||
{ kind: 'water', name: 'Regen', source: null, osm_ref: null },
|
||||
{ kind: 'portage', name: 'Wehr', source: 'tagged', osm_ref: 'way/1' },
|
||||
],
|
||||
},
|
||||
{ status: 'failed', code: 'no_region', at: B },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('PADROUTE-MCP-004: a trip the caller cannot see is refused before anything is planned', async () => {
|
||||
const { mcp, routing } = makeMcp(false);
|
||||
const result = (await mcp.planPaddleRoute({ tripId: 9, legs: [{ from: A, to: B }] }, ctx)) as McpTextResult;
|
||||
expect(result.isError).toBe(true);
|
||||
expect(routing.route).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* PaddleRoutingService over a snapshot db and a temp data folder: a region's
|
||||
* network written and recorded as the import does it, legs planned on the
|
||||
* smallest region covering both ends, the failures per leg, the files swept,
|
||||
* replaced and discarded, and the counts the admin list reads.
|
||||
*/
|
||||
import { PaddleRegions } from '../../../../src/db/entities/PaddleRegions.entity';
|
||||
import { PaddleWaterGraphs } from '../../../../src/db/entities/PaddleWaterGraphs.entity';
|
||||
import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module';
|
||||
import { DataPathsService } from '../../../../src/nest/app-config/data-paths.service';
|
||||
import { withRequestContext } from '../../../../src/nest/database/request-context';
|
||||
import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import';
|
||||
import { PaddleRoutingModule } from '../../../../src/nest/paddle-routing/paddle-routing.module';
|
||||
import { PaddleRoutingService } from '../../../../src/nest/paddle-routing/paddle-routing.service';
|
||||
import { PADDLE_ROUTER_RUNNER, type RouterRunner } from '../../../../src/nest/paddle-routing/router-runner';
|
||||
import type { BuiltWaterGraph } from '../../../../src/nest/paddle-routing/water-graph.contract';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { countRows, insertRow, updateRows } from '../../../helpers/factories/rows';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../../helpers/test-module';
|
||||
import { WaterWorld } from '../../../helpers/water-world';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { paddleRouteRequestSchema, type PaddleRouteInput } from '@trek/shared';
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const log = vi.hoisted(() => ({ logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
|
||||
...log,
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
|
||||
let dir: string;
|
||||
let t: TestModule | undefined;
|
||||
let service: PaddleRoutingService;
|
||||
|
||||
/** Two pieces of water: a long river in the west region, a short one inside it in the east region's box too. */
|
||||
const west = new WaterWorld({ lat: 49, lng: 12 });
|
||||
west.line(
|
||||
[
|
||||
[0, 6000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
const east = new WaterWorld({ lat: 49, lng: 12.5 });
|
||||
east.line(
|
||||
[
|
||||
[0, 3000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Naab' },
|
||||
);
|
||||
|
||||
let westGraph: BuiltWaterGraph;
|
||||
let eastGraph: BuiltWaterGraph;
|
||||
|
||||
async function graphOf(world: WaterWorld): Promise<BuiltWaterGraph> {
|
||||
const path = join(dir, `world-${Math.random().toString(36).slice(2)}.osm.pbf`);
|
||||
await writeFile(path, world.pbf());
|
||||
const { graph } = await importExtract(path, 'region', () => undefined);
|
||||
if (!graph) throw new Error('no network');
|
||||
return graph;
|
||||
}
|
||||
|
||||
const orm = () => {
|
||||
if (!t) throw new Error('No container booted');
|
||||
return t.get(MikroORM);
|
||||
};
|
||||
const inContext = <T>(work: () => Promise<T>) => withRequestContext(orm(), work);
|
||||
const graphsDir = () => join(dir, 'paddle', 'graphs');
|
||||
|
||||
/** A region's network as the import hands it over: written, then recorded. */
|
||||
async function install(regionId: string, built: BuiltWaterGraph) {
|
||||
if ((await countRows(orm(), PaddleRegions, { id: regionId })) === 0) {
|
||||
await insertRow(orm(), PaddleRegions, { id: regionId, name: regionId, status: 'ready' });
|
||||
}
|
||||
const file = await service.writeGraphFile(regionId, built);
|
||||
const previous = await inContext(() => service.recordGraph(regionId, file, built));
|
||||
await service.replaced(previous, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
const plan = (input: PaddleRouteInput) => inContext(() => service.route(paddleRouteRequestSchema.parse(input)));
|
||||
|
||||
async function boot(runner?: RouterRunner) {
|
||||
await t?.close();
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [AppConfigModule, PaddleRoutingModule],
|
||||
overrides: [
|
||||
{ provide: DataPathsService, useValue: { dataDir: dir, tmpDir: dir } },
|
||||
...(runner ? [{ provide: PADDLE_ROUTER_RUNNER, useValue: runner }] : []),
|
||||
],
|
||||
});
|
||||
service = t.get(PaddleRoutingService);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-paddle-routing-'));
|
||||
westGraph = await graphOf(west);
|
||||
eastGraph = await graphOf(east);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(db);
|
||||
vi.clearAllMocks();
|
||||
await rm(join(dir, 'paddle'), { recursive: true, force: true });
|
||||
await boot();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await t?.close();
|
||||
t = undefined;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
db.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('PaddleRoutingService', () => {
|
||||
it('PADROUTE-SVC-001: plans a leg on the region that covers it, and fails a leg no region covers on its own', async () => {
|
||||
await install('oberpfalz', westGraph);
|
||||
const answer = await plan({
|
||||
legs: [
|
||||
{ from: west.at(0, 5000), to: west.at(0, 1000) },
|
||||
{ from: west.at(0, 5000), to: { lat: 10, lng: 10 } },
|
||||
{ from: { lat: 10, lng: 10 }, to: west.at(0, 1000) },
|
||||
],
|
||||
});
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok' });
|
||||
expect(answer.legs[1]).toEqual({ status: 'failed', code: 'no_region', at: { lat: 10, lng: 10 } });
|
||||
expect(answer.legs[2]).toEqual({ status: 'failed', code: 'no_region', at: { lat: 10, lng: 10 } });
|
||||
expect(answer.graph_rev).toBe(westGraph.rev);
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-002: a leg between two regions that share no box fails as cross region; no region at all answers no rev', async () => {
|
||||
await install('oberpfalz', westGraph);
|
||||
await install('niederbayern', eastGraph);
|
||||
const cross = await plan({ legs: [{ from: west.at(0, 5000), to: east.at(0, 2000) }] });
|
||||
expect(cross.legs[0]).toEqual({ status: 'failed', code: 'cross_region', at: east.at(0, 2000) });
|
||||
expect(cross.graph_rev).toBeNull();
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-003: legs of two regions are planned each on its own, the revs joined', async () => {
|
||||
await install('oberpfalz', westGraph);
|
||||
await install('niederbayern', eastGraph);
|
||||
const answer = await plan({
|
||||
legs: [
|
||||
{ from: west.at(0, 5000), to: west.at(0, 1000) },
|
||||
{ from: east.at(0, 2500), to: east.at(0, 500) },
|
||||
],
|
||||
});
|
||||
expect(answer.legs.map((leg) => leg.status)).toEqual(['ok', 'ok']);
|
||||
expect(answer.graph_rev?.split('+').sort()).toEqual([westGraph.rev, eastGraph.rev].sort());
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-004: a runner that fails answers busy for its legs, one past its time timeout', async () => {
|
||||
const runner: RouterRunner = {
|
||||
run: vi.fn().mockRejectedValueOnce(new Error('worker exited')).mockRejectedValueOnce(new Error('timeout')),
|
||||
forget: vi.fn(),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await boot(runner);
|
||||
await install('oberpfalz', westGraph);
|
||||
const leg = { from: west.at(0, 5000), to: west.at(0, 1000) };
|
||||
expect((await plan({ legs: [leg] })).legs).toEqual([{ status: 'failed', code: 'busy', at: null }]);
|
||||
expect((await plan({ legs: [leg] })).legs).toEqual([{ status: 'failed', code: 'timeout', at: null }]);
|
||||
expect(log.logInfo).toHaveBeenCalledWith(expect.stringContaining('worker exited'));
|
||||
await t?.close();
|
||||
t = undefined;
|
||||
expect(runner.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-005: a rebuilt region replaces its file and the router forgets the old one; the same build keeps it', async () => {
|
||||
const runner: RouterRunner = { run: vi.fn(), forget: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
|
||||
await boot(runner);
|
||||
const first = await install('oberpfalz', westGraph);
|
||||
expect(await readdir(graphsDir())).toEqual([first]);
|
||||
const again = await install('oberpfalz', westGraph);
|
||||
expect(again).toBe(first);
|
||||
expect(runner.forget).not.toHaveBeenCalled();
|
||||
|
||||
const second = await install('oberpfalz', eastGraph);
|
||||
expect(await readdir(graphsDir())).toEqual([second]);
|
||||
expect(runner.forget).toHaveBeenCalledWith(join(graphsDir(), first));
|
||||
expect(await countRows(orm(), PaddleWaterGraphs)).toBe(1);
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-006: a file whose import did not commit is discarded; the sweep clears files no region names and halves of a write', async () => {
|
||||
const kept = await install('oberpfalz', westGraph);
|
||||
const orphan = await service.writeGraphFile('gone/region', eastGraph);
|
||||
expect(orphan).toMatch(/^gone_region\./);
|
||||
await writeFile(join(graphsDir(), 'half.wgraph.tmp'), 'x');
|
||||
await writeFile(join(graphsDir(), 'notes.txt'), 'kept');
|
||||
await inContext(() => service.sweep());
|
||||
expect((await readdir(graphsDir())).sort()).toEqual([kept, 'notes.txt'].sort());
|
||||
|
||||
const unrecorded = await service.writeGraphFile('other', eastGraph);
|
||||
await service.discard(unrecorded);
|
||||
expect(existsSync(join(graphsDir(), unrecorded))).toBe(false);
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-007: lists the network of each region with its counts, its build time and its size; a broken row lists none', async () => {
|
||||
await install('oberpfalz', westGraph);
|
||||
await install('niederbayern', eastGraph);
|
||||
await updateRows(orm(), PaddleWaterGraphs, { region_id: 'niederbayern' }, { stats: 'not json' });
|
||||
const networks = await inContext(() => service.networks());
|
||||
expect([...networks.keys()]).toEqual(['oberpfalz']);
|
||||
expect(networks.get('oberpfalz')).toMatchObject({
|
||||
...westGraph.stats,
|
||||
bytes: westGraph.bytes.byteLength,
|
||||
built_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.*Z$/),
|
||||
});
|
||||
});
|
||||
|
||||
it('PADROUTE-SVC-008: the boot sweep runs without a folder, and a failing sweep is logged, never thrown', async () => {
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
await mkdir(join(dir, 'paddle'), { recursive: true });
|
||||
await writeFile(graphsDir(), 'a file where the folder should be');
|
||||
vi.spyOn(service, 'sweep').mockRejectedValueOnce(new Error('disk gone'));
|
||||
await service.onApplicationBootstrap();
|
||||
expect(log.logError).toHaveBeenCalledWith(expect.stringContaining('disk gone'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Where the router runs (PAD-11): the graphs a thread keeps loaded, the
|
||||
* runner on the calling thread, the router thread's own script, and the
|
||||
* runner that talks to a thread, here with small threads written by the
|
||||
* test that answer, fail, hang or die. Also the graph file's format.
|
||||
*/
|
||||
import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import';
|
||||
import { GraphCache, InlineRouterRunner, type RouteJob } from '../../../../src/nest/paddle-routing/router-runner';
|
||||
import { decodeWaterGraph, encodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph';
|
||||
import { WorkerRouterRunner } from '../../../../src/nest/paddle-routing/worker-router-runner';
|
||||
import { WaterWorld } from '../../../helpers/water-world';
|
||||
|
||||
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The router thread's port, as its script sees it: messages in, messages out. */
|
||||
const port = vi.hoisted(() => {
|
||||
const listeners: Array<(message: unknown) => void> = [];
|
||||
return {
|
||||
on: (event: string, listener: (message: unknown) => void) => {
|
||||
if (event === 'message') listeners.push(listener);
|
||||
},
|
||||
emit: (_event: 'message', message: unknown) => listeners.forEach((listener) => listener(message)),
|
||||
postMessage: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('node:worker_threads', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('node:worker_threads')>()),
|
||||
parentPort: port,
|
||||
}));
|
||||
|
||||
let dir: string;
|
||||
let file: string;
|
||||
const world = new WaterWorld();
|
||||
world.line(
|
||||
[
|
||||
[0, 4000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
const PROFILE = { boat: 'kayak' as const, lakeStyle: 'direct' as const, lockPolicy: 'auto' as const };
|
||||
const job = (path: string): RouteJob => ({
|
||||
file: path,
|
||||
legs: [{ from: world.at(0, 3500), to: world.at(0, 500) }],
|
||||
profile: PROFILE,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-router-runner-'));
|
||||
const pbf = join(dir, 'world.osm.pbf');
|
||||
await writeFile(pbf, world.pbf());
|
||||
const { graph } = await importExtract(pbf, 'runner', () => undefined);
|
||||
if (!graph) throw new Error('no network');
|
||||
file = join(dir, 'region.wgraph');
|
||||
await writeFile(file, graph.bytes);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the water graph file', () => {
|
||||
it('PADRUN-001: the same graph gives the same bytes and revision; a foreign or newer file is refused', async () => {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const bytes = await readFile(file);
|
||||
const again = encodeWaterGraph(decodeWaterGraph(bytes));
|
||||
expect(Buffer.compare(again.bytes, bytes)).toBe(0);
|
||||
expect(again.rev).toMatch(/^[0-9a-f]{16}$/);
|
||||
|
||||
expect(() => decodeWaterGraph(Buffer.from('PK\u0003\u0004 not a graph at all'))).toThrow('Not a water graph');
|
||||
// The magic TREKWG, then the head's length, then the head.
|
||||
const headLength = bytes.readUInt32LE(6);
|
||||
const head = bytes.toString('utf8', 10, 10 + headLength);
|
||||
const newer = Buffer.from(bytes);
|
||||
newer.write(head.replace(/"format":\d+/, '"format":9'), 10, 'utf8');
|
||||
expect(() => decodeWaterGraph(newer)).toThrow(/format 9/);
|
||||
const lacking = Buffer.from(bytes);
|
||||
lacking.write(head.replace('"name":"nodeLat"', '"name":"nodeLaX"'), 10, 'utf8');
|
||||
expect(() => decodeWaterGraph(lacking)).toThrow('Water graph lacks nodeLat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GraphCache and the runner on the calling thread', () => {
|
||||
it('PADRUN-002: loads a graph once, keeps four, forgets on request, and retries a file that failed to load', async () => {
|
||||
const cache = new GraphCache();
|
||||
const first = await cache.get(file);
|
||||
expect(await cache.get(file)).toBe(first);
|
||||
const copies = await Promise.all(
|
||||
[1, 2, 3, 4].map(async (index) => {
|
||||
const copy = join(dir, `copy-${index}.wgraph`);
|
||||
await writeFile(copy, await (await import('node:fs/promises')).readFile(file));
|
||||
await cache.get(copy);
|
||||
return copy;
|
||||
}),
|
||||
);
|
||||
// Four others were loaded since: the first went.
|
||||
expect(await cache.get(file)).not.toBe(first);
|
||||
const kept = await cache.get(copies[3] as string);
|
||||
cache.forget(copies[3] as string);
|
||||
expect(await cache.get(copies[3] as string)).not.toBe(kept);
|
||||
|
||||
const missing = join(dir, 'missing.wgraph');
|
||||
await expect(cache.get(missing)).rejects.toThrow();
|
||||
await writeFile(missing, await (await import('node:fs/promises')).readFile(file));
|
||||
await expect(cache.get(missing)).resolves.toBeDefined();
|
||||
await unlink(missing);
|
||||
cache.clear();
|
||||
});
|
||||
|
||||
it('PADRUN-003: the inline runner plans the job and closes', async () => {
|
||||
const runner = new InlineRouterRunner();
|
||||
const [leg] = await runner.run(job(file));
|
||||
expect(leg).toMatchObject({ status: 'ok' });
|
||||
runner.forget(file);
|
||||
await expect(runner.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('PADROUTE-WORKER-001: the router thread plans what it is sent, forgets on request and answers a failure as a message', async () => {
|
||||
await import('../../../../src/nest/paddle-routing/paddle-router.worker');
|
||||
port.emit('message', { id: 1, job: job(file) });
|
||||
await vi.waitFor(() =>
|
||||
expect(port.postMessage).toHaveBeenCalledWith({ id: 1, legs: [expect.objectContaining({ status: 'ok' })] }),
|
||||
);
|
||||
port.emit('message', { id: 0, forget: file });
|
||||
port.emit('message', { id: 2 });
|
||||
port.emit('message', { id: 3, job: job(join(dir, 'nowhere.wgraph')) });
|
||||
await vi.waitFor(() =>
|
||||
expect(port.postMessage).toHaveBeenCalledWith({ id: 3, error: expect.stringContaining('nowhere.wgraph') }),
|
||||
);
|
||||
expect(port.postMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
/** A thread script the runner can start: plain JS, so it runs without the server's loader. */
|
||||
async function script(name: string, body: string): Promise<string> {
|
||||
const path = join(dir, `${name}.cjs`);
|
||||
await writeFile(path, `const { parentPort } = require('node:worker_threads');\n${body}\n`);
|
||||
return path;
|
||||
}
|
||||
|
||||
describe('WorkerRouterRunner', () => {
|
||||
it('PADRUN-004: hands the job to its thread and answers its legs; a failure the thread reports rejects', async () => {
|
||||
const entry = await script(
|
||||
'answers',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.forget) return;
|
||||
if (m.job.file === 'bad') parentPort.postMessage({ id: m.id, error: 'graph broken' });
|
||||
else if (m.job.file === 'silent') parentPort.postMessage({ id: m.id });
|
||||
else parentPort.postMessage({ id: m.id, legs: [{ status: 'failed', code: 'no_water_path', at: null }] });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerRouterRunner(entry);
|
||||
await expect(runner.run(job('good'))).resolves.toEqual([{ status: 'failed', code: 'no_water_path', at: null }]);
|
||||
await expect(runner.run(job('bad'))).rejects.toThrow('graph broken');
|
||||
await expect(runner.run(job('silent'))).rejects.toThrow('router failed');
|
||||
runner.forget('good');
|
||||
await runner.close();
|
||||
});
|
||||
|
||||
it('PADRUN-005: a thread that dies fails what it held, and the next job gets a fresh thread', async () => {
|
||||
const entry = await script(
|
||||
'dies',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.job.file === 'crash') process.exit(3);
|
||||
parentPort.postMessage({ id: m.id, legs: [] });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerRouterRunner(entry);
|
||||
await expect(runner.run(job('crash'))).rejects.toThrow('router thread ended');
|
||||
await expect(runner.run(job('fine'))).resolves.toEqual([]);
|
||||
await runner.close();
|
||||
});
|
||||
|
||||
it('PADRUN-006: a thread that throws fails its jobs; closing fails what is still waiting', async () => {
|
||||
const entry = await script(
|
||||
'throws',
|
||||
`parentPort.on('message', (m) => {
|
||||
if (m.job.file === 'throw') setTimeout(() => { throw new Error('boom'); });
|
||||
});`,
|
||||
);
|
||||
const runner = new WorkerRouterRunner(entry);
|
||||
await expect(runner.run(job('throw'))).rejects.toThrow('boom');
|
||||
const waiting = runner.run(job('never')).catch((err: Error) => err.message);
|
||||
await runner.close();
|
||||
expect(await waiting).toBe('closed');
|
||||
});
|
||||
|
||||
it('PADRUN-007: a job its thread leaves unanswered times out and the thread is started over', async () => {
|
||||
const entry = await script('hangs', `parentPort.on('message', () => undefined);`);
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
try {
|
||||
const runner = new WorkerRouterRunner(entry);
|
||||
const hanging = runner.run(job('hang'));
|
||||
const caught = hanging.catch((err: Error) => err.message);
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
expect(await caught).toBe('timeout');
|
||||
// The idle timer ends a thread nobody asks for long enough.
|
||||
runner.run(job('again')).catch(() => undefined);
|
||||
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
|
||||
await runner.close();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* PAD-11 from the extract to the planned leg: a piece of the world drawn in
|
||||
* the test, read by the region import, built into the water network and
|
||||
* routed on, as a region's read and a paddler's leg go in production.
|
||||
*/
|
||||
import { importExtract } from '../../../../src/nest/paddle-obstacles/paddle-import';
|
||||
import { WaterRouter, type RouteProfile } from '../../../../src/nest/paddle-routing/engine/router';
|
||||
import { decodeWaterGraph } from '../../../../src/nest/paddle-routing/water-graph';
|
||||
import { lineMetres, WaterWorld } from '../../../helpers/water-world';
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let dir: string;
|
||||
let n = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'trek-water-routing-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const KAYAK: RouteProfile = { boat: 'kayak', lakeStyle: 'direct', lockPolicy: 'auto' };
|
||||
const SUP: RouteProfile = { ...KAYAK, boat: 'sup' };
|
||||
|
||||
/** The world read as a region: its network's stats and a router on it. */
|
||||
async function built(world: WaterWorld) {
|
||||
const path = join(dir, `world-${n++}.osm.pbf`);
|
||||
await writeFile(path, world.pbf());
|
||||
const phases: string[] = [];
|
||||
const outcome = await importExtract(path, 'test-region', (phase) => {
|
||||
if (phases.at(-1) !== phase) phases.push(phase);
|
||||
});
|
||||
if (!outcome.graph) throw new Error('no network built');
|
||||
return { ...outcome.graph, phases, router: new WaterRouter(decodeWaterGraph(Buffer.from(outcome.graph.bytes))) };
|
||||
}
|
||||
|
||||
function ok(leg: PaddleRouteLeg): Extract<PaddleRouteLeg, { status: 'ok' }> {
|
||||
if (leg.status !== 'ok') throw new Error(`leg failed: ${leg.code}`);
|
||||
return leg;
|
||||
}
|
||||
|
||||
const kinds = (leg: PaddleRouteLeg) => [...new Set(ok(leg).segments.map((segment) => segment.kind))];
|
||||
|
||||
/** A river 6 km long, flowing south, its centre line at east 0, a node every 100 m (node `k` at north 6000 - 100k). */
|
||||
function river(world: WaterWorld, tags: Record<string, string> = {}) {
|
||||
return world.line(
|
||||
[
|
||||
[0, 6000],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen', ...tags },
|
||||
);
|
||||
}
|
||||
|
||||
describe('water routing from an extract (PAD-11)', () => {
|
||||
it('WATER-001: follows a river between two points beside it, snapped onto it, named', async () => {
|
||||
const world = new WaterWorld();
|
||||
river(world);
|
||||
const { router, stats, phases } = await built(world);
|
||||
expect(phases).toEqual(['read', 'build']);
|
||||
expect(stats.water_km).toBeCloseTo(6, 1);
|
||||
|
||||
const leg = ok(router.route(world.at(40, 5000), world.at(-30, 1000), KAYAK));
|
||||
expect(leg.distance_m).toBeGreaterThan(3950);
|
||||
expect(leg.distance_m).toBeLessThan(4050);
|
||||
expect(leg.snapped_m.from).toBeCloseTo(40, -1);
|
||||
expect(leg.segments).toEqual([expect.objectContaining({ kind: 'water', name: 'Regen' })]);
|
||||
expect(lineMetres(leg.geometry as [number, number][])).toBeCloseTo(leg.distance_m, -1);
|
||||
});
|
||||
|
||||
it('WATER-002: a point far from the water is off it, two waters that never meet have no way between', async () => {
|
||||
const world = new WaterWorld();
|
||||
river(world);
|
||||
world.line(
|
||||
[
|
||||
[3000, 6000],
|
||||
[3000, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Naab' },
|
||||
);
|
||||
const { router } = await built(world);
|
||||
const far = world.at(600, 3000);
|
||||
expect(router.route(world.at(0, 5000), far, KAYAK)).toEqual({ status: 'failed', code: 'not_on_water', at: far });
|
||||
expect(router.route(world.at(0, 5000), world.at(3000, 1000), KAYAK)).toEqual({
|
||||
status: 'failed',
|
||||
code: 'no_water_path',
|
||||
at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('WATER-003: carries round a weir on the mapped portage, and round one without a portage on a short straight carry', async () => {
|
||||
const world = new WaterWorld();
|
||||
const regen = river(world);
|
||||
// A weir drawn across the river at 4 km, its portage on the west bank.
|
||||
world.line(
|
||||
[
|
||||
[-30, 4000],
|
||||
[30, 4000],
|
||||
],
|
||||
{ waterway: 'weir', name: 'Lindenmühle' },
|
||||
30,
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 4060],
|
||||
[-25, 4050],
|
||||
[-25, 3950],
|
||||
[0, 3940],
|
||||
],
|
||||
{ canoe: 'portage', name: 'Umtragestelle' },
|
||||
40,
|
||||
);
|
||||
// A weir on the river's node at 2 km, with nothing mapped round it.
|
||||
world.tag(regen.refs[40] as number, { waterway: 'weir', name: 'Pielmühle' });
|
||||
const { router, stats } = await built(world);
|
||||
expect(stats.portages).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.inferred_portages).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const leg = ok(router.route(world.at(0, 5500), world.at(0, 500), KAYAK));
|
||||
const portages = leg.segments.filter((segment) => segment.kind === 'portage');
|
||||
expect(portages.map((segment) => segment.source)).toEqual(['tagged', 'inferred']);
|
||||
expect(portages[0]?.name).toBe('Umtragestelle');
|
||||
});
|
||||
|
||||
it('WATER-004: paddles grade 2 whitewater in a kayak and marks it, never in a SUP; grade 3 is no touring water', async () => {
|
||||
const world = new WaterWorld();
|
||||
world.line(
|
||||
[
|
||||
[0, 6000],
|
||||
[0, 3000],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 3000],
|
||||
[0, 2500],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen', 'whitewater:section_grade': '2' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 2500],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river', name: 'Regen' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[5000, 6000],
|
||||
[5000, 3000],
|
||||
],
|
||||
{ waterway: 'river', name: 'Wild', 'whitewater:section_grade': '3' },
|
||||
);
|
||||
const { router } = await built(world);
|
||||
const kayak = ok(router.route(world.at(0, 5000), world.at(0, 1000), KAYAK));
|
||||
expect(kayak.segments.find((segment) => segment.kind === 'rapids')).toMatchObject({ grade: 2 });
|
||||
expect(router.route(world.at(0, 5000), world.at(0, 1000), SUP)).toMatchObject({ status: 'failed' });
|
||||
expect(router.route(world.at(5000, 5500), world.at(5000, 3500), KAYAK)).toMatchObject({ status: 'failed' });
|
||||
});
|
||||
|
||||
it('WATER-005: crosses a lake straight over, along its shore when asked, and a SUP keeps to the shore anyway', async () => {
|
||||
const world = new WaterWorld();
|
||||
// In from the north, a lake 1 km wide and 3 km long, out to the south.
|
||||
world.line(
|
||||
[
|
||||
[0, 6000],
|
||||
[0, 4000],
|
||||
],
|
||||
{ waterway: 'river', name: 'Havel' },
|
||||
);
|
||||
world.ring(
|
||||
[
|
||||
[-500, 4000],
|
||||
[500, 4000],
|
||||
[500, 1000],
|
||||
[-500, 1000],
|
||||
],
|
||||
{ natural: 'water', name: 'Woblitz' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 1000],
|
||||
[0, -1000],
|
||||
],
|
||||
{ waterway: 'river', name: 'Havel' },
|
||||
);
|
||||
const { router, stats } = await built(world);
|
||||
expect(stats.lakes).toBe(1);
|
||||
|
||||
const from = world.at(0, 5000);
|
||||
const to = world.at(0, 0);
|
||||
const direct = ok(router.route(from, to, KAYAK));
|
||||
expect(kinds(direct)).toEqual(['water', 'lake']);
|
||||
expect(direct.segments.find((segment) => segment.kind === 'lake')?.name).toBe('Woblitz');
|
||||
const shore = ok(router.route(from, to, { ...KAYAK, lakeStyle: 'shore' }));
|
||||
const sup = ok(router.route(from, to, SUP));
|
||||
expect(shore.distance_m).toBeGreaterThan(direct.distance_m);
|
||||
expect(sup.distance_m).toBeGreaterThanOrEqual(direct.distance_m);
|
||||
// A point out on the lake is on the water too.
|
||||
expect(router.route(world.at(200, 2500), to, KAYAK).status).toBe('ok');
|
||||
});
|
||||
|
||||
it('WATER-006: goes round an island of a multipolygon lake, not across it', async () => {
|
||||
const world = new WaterWorld();
|
||||
const outer = world.ring([
|
||||
[-1000, 1000],
|
||||
[1000, 1000],
|
||||
[1000, -1000],
|
||||
[-1000, -1000],
|
||||
]);
|
||||
const island = world.ring([
|
||||
[-300, 300],
|
||||
[300, 300],
|
||||
[300, -300],
|
||||
[-300, -300],
|
||||
]);
|
||||
world.relation(
|
||||
[
|
||||
{ type: 'way', ref: outer, role: 'outer' },
|
||||
{ type: 'way', ref: island, role: 'inner' },
|
||||
],
|
||||
{ type: 'multipolygon', natural: 'water', name: 'Müritz' },
|
||||
);
|
||||
const { router, stats } = await built(world);
|
||||
expect(stats.lakes).toBe(1);
|
||||
const leg = ok(router.route(world.at(0, 800), world.at(0, -800), KAYAK));
|
||||
expect(leg.distance_m).toBeGreaterThan(1600);
|
||||
const crossesIsland = leg.geometry.some(([lat, lng]) => {
|
||||
const centre = world.at(0, 0);
|
||||
const edge = world.at(250, 250);
|
||||
return Math.abs(lat - centre.lat) < edge.lat - centre.lat && Math.abs(lng - centre.lng) < edge.lng - centre.lng;
|
||||
});
|
||||
expect(crossesIsland).toBe(false);
|
||||
});
|
||||
|
||||
it('WATER-007: joins a river end close to other water as water, bridges a wider break as a marked gap', async () => {
|
||||
const world = new WaterWorld();
|
||||
world.line(
|
||||
[
|
||||
[0, 3000],
|
||||
[0, 2000],
|
||||
],
|
||||
{ waterway: 'river' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 1980],
|
||||
[0, 1000],
|
||||
],
|
||||
{ waterway: 'river' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 920],
|
||||
[0, 0],
|
||||
],
|
||||
{ waterway: 'river' },
|
||||
);
|
||||
const { router, stats } = await built(world);
|
||||
expect(stats.gaps).toBeGreaterThanOrEqual(1);
|
||||
const leg = ok(router.route(world.at(0, 2900), world.at(0, 100), KAYAK));
|
||||
expect(kinds(leg)).toContain('gap');
|
||||
});
|
||||
|
||||
it('WATER-008: takes the canoe pass beside a weir, and leaves a mill race to the mill', async () => {
|
||||
const world = new WaterWorld();
|
||||
const regen = river(world);
|
||||
world.tag(regen.refs[30] as number, { waterway: 'weir' });
|
||||
world.line(
|
||||
[
|
||||
[0, 3200],
|
||||
[-40, 3100],
|
||||
[-40, 2900],
|
||||
[0, 2800],
|
||||
],
|
||||
{ waterway: 'canoe_pass', name: 'Bootsgasse' },
|
||||
50,
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[0, 5000],
|
||||
[800, 4500],
|
||||
[800, 1500],
|
||||
[0, 1000],
|
||||
],
|
||||
{ waterway: 'canal', usage: 'headrace' },
|
||||
);
|
||||
const { router } = await built(world);
|
||||
const leg = ok(router.route(world.at(0, 5500), world.at(0, 500), KAYAK));
|
||||
expect(kinds(leg)).toContain('pass');
|
||||
expect(kinds(leg)).not.toContain('portage');
|
||||
expect(leg.distance_m).toBeLessThan(5300);
|
||||
});
|
||||
|
||||
it('WATER-011: goes through an open lock by its own name, carries round it when told to, and only on a mapped way round a closed one', async () => {
|
||||
const world = new WaterWorld();
|
||||
const canal = (east: number, lock: Record<string, string>) => {
|
||||
world.line(
|
||||
[
|
||||
[east, 3000],
|
||||
[east, 2000],
|
||||
],
|
||||
{ waterway: 'canal', name: 'Müritz-Havel-Wasserstraße' },
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[east, 2000],
|
||||
[east, 1900],
|
||||
],
|
||||
{ waterway: 'canal', name: 'Müritz-Havel-Wasserstraße', lock: 'yes', ...lock },
|
||||
20,
|
||||
);
|
||||
world.line(
|
||||
[
|
||||
[east, 1900],
|
||||
[east, 0],
|
||||
],
|
||||
{ waterway: 'canal', name: 'Müritz-Havel-Wasserstraße' },
|
||||
);
|
||||
};
|
||||
canal(0, { lock_name: 'Schleuse Diemitz' });
|
||||
canal(3000, { lock_name: 'Schleuse Zu', boat: 'no' });
|
||||
canal(6000, { lock_name: 'Schleuse Gesperrt', boat: 'no' });
|
||||
world.line(
|
||||
[
|
||||
[6000, 2050],
|
||||
[6030, 2000],
|
||||
[6030, 1900],
|
||||
[6000, 1850],
|
||||
],
|
||||
{ canoe: 'portage' },
|
||||
30,
|
||||
);
|
||||
const { router, stats } = await built(world);
|
||||
expect(stats.locks).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const through = ok(router.route(world.at(0, 2800), world.at(0, 200), KAYAK));
|
||||
expect(through.segments.find((segment) => segment.kind === 'lock')?.name).toBe('Schleuse Diemitz');
|
||||
const carried = ok(router.route(world.at(0, 2800), world.at(0, 200), { ...KAYAK, lockPolicy: 'portage' }));
|
||||
expect(kinds(carried)).toContain('portage');
|
||||
expect(kinds(carried)).not.toContain('lock');
|
||||
|
||||
expect(router.route(world.at(3000, 2800), world.at(3000, 200), KAYAK).status).toBe('failed');
|
||||
const round = ok(router.route(world.at(6000, 2800), world.at(6000, 200), KAYAK));
|
||||
expect(round.segments.find((segment) => segment.kind === 'portage')?.source).toBe('tagged');
|
||||
});
|
||||
|
||||
it('WATER-009: a search past its time answers timeout, not a route; a leg along one stretch needs no search', async () => {
|
||||
const world = new WaterWorld();
|
||||
for (const [from, to] of [
|
||||
[6000, 3000],
|
||||
[3000, 0],
|
||||
]) {
|
||||
world.line(
|
||||
[
|
||||
[0, from as number],
|
||||
[0, to as number],
|
||||
],
|
||||
{ waterway: 'river' },
|
||||
);
|
||||
}
|
||||
const { router } = await built(world);
|
||||
expect(router.route(world.at(0, 5000), world.at(0, 4000), KAYAK, Date.now() - 1).status).toBe('ok');
|
||||
expect(router.route(world.at(0, 5000), world.at(0, 1000), KAYAK, Date.now() - 1)).toEqual({
|
||||
status: 'failed',
|
||||
code: 'timeout',
|
||||
at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('WATER-010: an extract without water it routes on builds no network', async () => {
|
||||
const world = new WaterWorld();
|
||||
world.line(
|
||||
[
|
||||
[0, 1000],
|
||||
[0, 0],
|
||||
],
|
||||
{ highway: 'path' },
|
||||
);
|
||||
const path = join(dir, `world-${n++}.osm.pbf`);
|
||||
await writeFile(path, world.pbf());
|
||||
const outcome = await importExtract(path, 'dry', () => undefined);
|
||||
expect(outcome.graph).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -183,6 +183,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
});
|
||||
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
|
||||
@@ -297,6 +298,7 @@ describe('ToursService planner contracts (mock-only; SQLite rollback semantics a
|
||||
water_context: null,
|
||||
paddle_speed_kmh: null,
|
||||
portage_speed_kmh: null,
|
||||
paddle_lake_style: null,
|
||||
});
|
||||
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
|
||||
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
|
||||
|
||||
Reference in New Issue
Block a user