Files
watermaps/apps/api/tests/fairways.test.ts
T
BuTzZ 9813a1fafb
Test and publish container images / test (push) Successful in 2m21s
Test and publish container images / publish (push) Failing after 4s
Optimized routing
2026-07-27 10:18:31 +02:00

639 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildRoute, haversineDistanceNm } from "@watermaps/shared";
import { createCache } from "../src/services/cache.js";
import {
FairwayService,
fairwayRowsToGraph,
localFairwaysToGraph,
mergeConnectedFairwayGraphs,
overpassToGraph
} from "../src/services/fairways.js";
const EMDEN_CANAL_REGRESSION_WAYS: Parameters<typeof localFairwaysToGraph>[0] = [
{
id: "28021006-tail",
bbox: [7.4498965, 53.4492034, 7.4512561, 53.4500483],
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
coordinates: [
[53.4492034, 7.4498965],
[53.4495624, 7.4505731],
[53.4499672, 7.4511607],
[53.4499875, 7.4511887],
[53.4500483, 7.4512561]
]
},
{
id: "278807710",
bbox: [7.4512561, 53.4500483, 7.4518959, 53.4504899],
tags: {
lock: "yes",
name: "Ems-Jade-Kanal",
width: "8",
waterway: "canal"
},
coordinates: [
[53.4500483, 7.4512561],
[53.4502057, 7.4514971],
[53.4504899, 7.4518959]
]
},
{
id: "278807709",
bbox: [7.4518959, 53.4449969, 7.5736929, 53.4650304],
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
coordinates: [
[53.4504899, 7.4518959],
[53.4505236, 7.4519531],
[53.4509222, 7.4525426],
[53.4516529, 7.453623],
[53.4584592, 7.463384],
[53.4595046, 7.4648841],
[53.4599438, 7.465618],
[53.460753, 7.4673133],
[53.4616984, 7.46945],
[53.4620622, 7.4701175],
[53.4625348, 7.4707952],
[53.4627636, 7.4711325],
[53.4629466, 7.4713664],
[53.4630916, 7.4715145],
[53.4632388, 7.4716743],
[53.4633943, 7.4717923],
[53.4636131, 7.4718959],
[53.4639548, 7.4720761],
[53.4642607, 7.4722805],
[53.4646149, 7.4724784],
[53.4647858, 7.472612],
[53.4650071, 7.4729838],
[53.4650304, 7.4733641],
[53.4649736, 7.4737867],
[53.4645791, 7.4743077],
[53.4642518, 7.4745346],
[53.4640847, 7.4746604],
[53.4624017, 7.4759267],
[53.4618827, 7.4763916],
[53.4602632, 7.479245],
[53.4587935, 7.4819111],
[53.4585086, 7.482532],
[53.4583429, 7.482893],
[53.4570519, 7.4861185],
[53.455688, 7.4897057],
[53.4554283, 7.4910088],
[53.4543892, 7.4963485],
[53.4532243, 7.5021539],
[53.4531102, 7.5027225],
[53.4520897, 7.5077114],
[53.4519492, 7.5083984],
[53.4508683, 7.5126327],
[53.4490515, 7.5208022],
[53.4489869, 7.5211006],
[53.4485124, 7.5232942],
[53.4466401, 7.5319367],
[53.4451989, 7.5385911],
[53.4450014, 7.5399242],
[53.4449969, 7.5406774],
[53.4456267, 7.5463127],
[53.4457299, 7.5470025],
[53.446674, 7.5509229],
[53.4477404, 7.55515],
[53.4478348, 7.5554891],
[53.4491825, 7.5603283],
[53.4525244, 7.5722942],
[53.4529207, 7.5736929]
]
}
];
describe("fairway graph extraction", () => {
it("reports that no routing source is configured", async () => {
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false
});
const lookup = await service.getGraphsForRoute({
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
});
expect(lookup.graphs).toEqual([]);
expect(lookup.failures).toMatchObject([
{
source: "configuration",
error: expect.objectContaining({ message: "No fairway source is configured" })
}
]);
await service.close();
});
it("retries a configured local routing index after a missing file is repaired", async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-missing-index-"));
const localDataPath = join(temporaryDirectory, "missing-fairways.json");
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false,
localDataPath
});
const request = {
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
};
try {
const missingLookup = await service.getGraphsForRoute(request);
expect(missingLookup.graphs).toEqual([]);
expect(missingLookup.failures).toMatchObject([
{
source: "local",
error: expect.objectContaining({ code: "ENOENT" })
}
]);
await writeFile(
localDataPath,
JSON.stringify({
version: 1,
source: "repaired-test",
ways: [
{
id: "repaired-way",
bbox: [7.4509, 53.4498, 7.4742, 53.4646],
tags: { waterway: "canal", name: "Repaired test fairway" },
coordinates: [
[53.4498, 7.4509],
[53.4646, 7.4742]
]
}
]
})
);
const repairedLookup = await service.getGraphsForRoute(request);
expect(repairedLookup.failures).toEqual([]);
expect(repairedLookup.graphs).toHaveLength(1);
expect(repairedLookup.graphs[0]?.edges[0]?.source).toBe(
"local-geofabrik-repaired-test"
);
} finally {
await service.close();
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("builds a routable graph from the persistent local Geofabrik format", () => {
const graph = localFairwaysToGraph(
[
{
id: "local-1",
bbox: [7.15, 53.62, 7.17, 53.71],
tags: { route: "ferry", name: "NorddeichNorderney" },
coordinates: [
[53.6234, 7.1559],
[53.66, 7.16],
[53.7023, 7.1658]
]
}
],
[7.0, 53.47, 7.32, 53.85],
"germany-test.osm.pbf"
);
const route = buildRoute(
{
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
},
graph ?? undefined
);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
});
it("does not invent a traversable line between nearby disconnected waterway endpoints", () => {
const graph = localFairwaysToGraph(
[
{
id: "west",
bbox: [7, 52, 7.01, 52],
tags: { waterway: "canal" },
coordinates: [
[52, 7],
[52, 7.01]
]
},
{
id: "east",
bbox: [7.011, 52, 7.02, 52],
tags: { waterway: "canal" },
coordinates: [
[52, 7.011],
[52, 7.02]
]
}
],
[6.9, 51.9, 7.1, 52.1],
"disconnected-test"
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.02 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph ?? undefined
);
expect(route).toBeNull();
expect(graph?.edges.some((edge) => edge.source === "fairway-graph-connectors")).toBe(false);
});
it("keeps the reported Ems-Jade-Kanal route on the full source geometry", () => {
const start = { lat: 53.4498, lon: 7.4509 };
const destination = { lat: 53.4646, lon: 7.4742 };
const graph = localFairwaysToGraph(
EMDEN_CANAL_REGRESSION_WAYS,
[7.3, 53.3, 7.7, 53.6],
"emden-canal-regression"
);
expect(graph).not.toBeNull();
if (!graph) {
throw new Error("Expected Ems-Jade-Kanal regression graph");
}
const route = buildRoute(
{
start,
destination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
},
graph
);
expect(route).not.toBeNull();
if (!route) {
throw new Error("Expected Ems-Jade-Kanal regression route");
}
const coordinates = route.geometry.coordinates;
const largestSegmentNm = coordinates.slice(1).reduce(
(largest, coordinate, index) =>
Math.max(
largest,
haversineDistanceNm(
{ lon: coordinates[index]![0], lat: coordinates[index]![1] },
{ lon: coordinate[0], lat: coordinate[1] }
)
),
0
);
expect(route.distanceNm).toBeGreaterThanOrEqual(1.2);
expect(route.distanceNm).toBeLessThanOrEqual(1.35);
expect(coordinates.length).toBeGreaterThanOrEqual(25);
expect(largestSegmentNm).toBeLessThan(0.55);
expect(coordinates).toContainEqual([7.4648841, 53.4595046]);
expect(coordinates).toContainEqual([7.46945, 53.4616984]);
expect(coordinates).toContainEqual([7.4724784, 53.4646149]);
expect(route.dataSources).toContain(
"local-geofabrik-emden-canal-regression"
);
expect(route.dataSources).not.toContain(
"fairway-graph:emden-east-ems-seed"
);
expect(route.routeSnaps?.start.distanceM).toBeLessThan(5);
expect(route.routeSnaps?.destination.distanceM).toBeLessThan(10);
expect(coordinates[0]).toEqual([
route.routeSnaps?.start.snapped.lon,
route.routeSnaps?.start.snapped.lat
]);
expect(coordinates.at(-1)).toEqual([
route.routeSnaps?.destination.snapped.lon,
route.routeSnaps?.destination.snapped.lat
]);
});
it("allows country-wide requests against the persistent local index", async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
const request = {
start: { lat: 52, lon: 4 },
destination: { lat: 52, lon: 12 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
};
try {
await writeFile(
localDataPath,
JSON.stringify({
version: 1,
source: "germany+netherlands",
ways: [
{
id: "country-wide-test",
bbox: [4, 52, 12, 52],
tags: { waterway: "canal", name: "Country-wide test fairway" },
coordinates: [
[52, 4],
[52, 12]
]
}
]
})
);
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false,
localDataPath
});
const lookup = await service.getGraphsForRoute(request);
const route = buildRoute(request, lookup.graphs[0]);
expect(lookup.failures).toEqual([]);
expect(lookup.graphs).toHaveLength(1);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain(
"local-geofabrik-germany+netherlands"
);
await service.close();
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("builds a routable graph from PostGIS fairway rows", () => {
const graph = fairwayRowsToGraph(
[
{
id: "1",
source: "osm",
source_id: "way-1",
name: "Harbour Reach",
min_depth_m: "4.2",
geometry: {
type: "LineString",
coordinates: [
[10, 54],
[10.04, 54.02]
]
}
},
{
id: "2",
source: "osm",
source_id: "way-2",
name: "Outer Reach",
min_depth_m: 4.2,
geometry: {
type: "LineString",
coordinates: [
[10.04, 54.02],
[10.1, 54.04]
]
}
}
],
[9.9, 53.9, 10.2, 54.1]
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start: { lat: 54, lon: 10 },
destination: { lat: 54.04, lon: 10.1 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.4 }
},
graph ?? undefined
);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("fairway-graph:postgis-9.900-53.900-10.200-54.100");
expect(route?.dataSources).toContain("postgis-osm");
});
it("routes the iPhone Emden coordinates through intermediate fairway vertices", () => {
const start = { lat: 53.3306, lon: 7.1752 };
const destination = { lat: 53.6741, lon: 7.1474 };
const graph = fairwayRowsToGraph(
[
{
id: "emden-main-reach",
source: "osm",
source_id: "way-main",
name: "Ems Fahrwasser",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.1752, 53.3306],
[7.1751368, 53.3331995],
[7.16, 53.42],
[7.1474, 53.55],
[7.18, 53.61]
]
}
},
{
id: "busetief-branch",
source: "osm",
source_id: "way-branch",
name: "Busetief",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.1474, 53.55],
[7.1414995, 53.6668156],
[7.1474, 53.6741]
]
}
}
],
[6.9974, 53.1806, 7.3252, 53.8241]
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start,
destination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
},
graph ?? undefined
);
expect(route).not.toBeNull();
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("postgis-osm");
expect(route?.routeSnaps?.start.requested).toEqual(start);
expect(route?.routeSnaps?.destination.requested).toEqual(destination);
expect(route?.geometry.coordinates[0]).toEqual([
route?.routeSnaps?.start.snapped.lon,
route?.routeSnaps?.start.snapped.lat
]);
expect(route?.geometry.coordinates.at(-1)).toEqual([
route?.routeSnaps?.destination.snapped.lon,
route?.routeSnaps?.destination.snapped.lat
]);
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
});
it("builds a graph from OSM/OpenSeaMap Overpass ways", () => {
const graph = overpassToGraph(
{
elements: [
{
type: "way",
id: 123,
tags: {
"seamark:type": "navigation_line",
"seamark:navigation_line:minimum_depth": "3.5"
},
geometry: [
{ lat: 54, lon: 10 },
{ lat: 54.02, lon: 10.05 }
]
}
]
},
[9.9, 53.9, 10.1, 54.1]
);
expect(graph).not.toBeNull();
expect(graph?.edges[0]?.source).toBe("osm-overpass-seamarks");
expect(graph?.edges[0]?.minDepthM).toBe(3.5);
});
it("accepts navigable canals but rejects explicitly closed waterways", () => {
const graph = overpassToGraph(
{
elements: [
{
type: "way",
id: 201,
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
geometry: [
{ lat: 51.65, lon: 7.35 },
{ lat: 51.66, lon: 7.4 }
]
},
{
type: "way",
id: 202,
tags: { waterway: "canal", boat: "no", name: "Gesperrter Kanal" },
geometry: [
{ lat: 51.66, lon: 7.4 },
{ lat: 51.67, lon: 7.45 }
]
}
]
},
[7.3, 51.6, 7.5, 51.7]
);
expect(graph).not.toBeNull();
expect(graph?.edges).toHaveLength(1);
expect(graph?.edges[0]?.name).toBe("Datteln-Hamm-Kanal");
});
it("topologically joins graph fragments from PostGIS and live data", () => {
const first = fairwayRowsToGraph(
[
{
id: "north",
source: "osm",
source_id: "north",
name: "Dortmund-Ems-Kanal",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
[7.3, 52.1],
[7.35, 52]
]
}
}
],
[7.2, 51.8, 7.6, 52.2]
);
const second = overpassToGraph(
{
elements: [
{
type: "way",
id: 301,
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
geometry: [
{ lat: 52, lon: 7.35 },
{ lat: 51.9, lon: 7.5 }
]
}
]
},
[7.2, 51.8, 7.6, 52.2]
);
const graph = mergeConnectedFairwayGraphs([first!, second!]);
const route = buildRoute(
{
start: { lat: 52.1, lon: 7.3 },
destination: { lat: 51.9, lon: 7.5 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 }
},
graph ?? undefined
);
expect(route).not.toBeNull();
expect(route?.dataSources).toContain("postgis-osm");
expect(route?.dataSources).toContain("osm-overpass-waterway-canal");
});
it("carries OSM vessel restrictions into the routing graph", () => {
const graph = fairwayRowsToGraph(
[
{
id: "restricted",
source: "osm",
source_id: "way-restricted",
name: "Niedrige Durchfahrt",
min_depth_m: "3.0",
properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" },
geometry: {
type: "LineString",
coordinates: [
[7, 52],
[7.04, 52]
]
}
}
],
[6.9, 51.9, 7.1, 52.1]
);
expect(graph?.edges[0]).toMatchObject({
maxAirDraftM: 2.4,
maxBeamM: 3.2,
maxDraughtM: 1.8,
oneway: true
});
expect(
buildRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.3, airDraftM: 2.5, beamM: 3 }
},
graph ?? undefined
)
).toBeNull();
});
});