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[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: "Norddeich–Norderney" }, 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(); }); });