Files
watermaps/apps/api/tests/fairways.test.ts
T

351 lines
9.5 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 } from "@watermaps/shared";
import { createCache } from "../src/services/cache.js";
import {
FairwayService,
fairwayRowsToGraph,
localFairwaysToGraph,
mergeConnectedFairwayGraphs,
overpassToGraph
} from "../src/services/fairways.js";
describe("fairway graph extraction", () => {
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("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 graphs = await service.getGraphsForRoute(request);
const route = buildRoute(request, graphs[0]);
expect(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.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]
]
}
}
],
[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?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.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();
});
});