mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
1bc82f97fd
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.
174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
/**
|
|
* 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');
|
|
});
|
|
});
|