import { describe, expect, it } from "vitest"; import { buildFairwayRoute, buildFairwayRoutes, buildManualRoute, buildRoute, haversineDistanceNm, requiredDepthM, type FairwayEdge, type FairwayGraph } from "../src/index.js"; const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 }; const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 }; const HAMM_INNENSTADT_MARINA = { lat: 51.6814536, lon: 7.8042615 }; const ALTERNATIVE_GRAPH: FairwayGraph = { id: "alternative-test", name: "Alternativen-Testnetz", maxSnapDistanceNm: 0.2, nodes: [ { id: "start", coordinate: { lat: 52, lon: 7 } }, { id: "branch-in", coordinate: { lat: 52, lon: 7.01 } }, { id: "upper", coordinate: { lat: 52.012, lon: 7.03 } }, { id: "lower", coordinate: { lat: 51.988, lon: 7.03 } }, { id: "branch-out", coordinate: { lat: 52, lon: 7.05 } }, { id: "destination", coordinate: { lat: 52, lon: 7.06 } } ], edges: [ edge("start-access", "start", "branch-in", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.01 }]), edge("main", "branch-in", "branch-out", [{ lat: 52, lon: 7.01 }, { lat: 52, lon: 7.05 }]), edge("upper-in", "branch-in", "upper", [{ lat: 52, lon: 7.01 }, { lat: 52.012, lon: 7.03 }]), edge("upper-out", "upper", "branch-out", [{ lat: 52.012, lon: 7.03 }, { lat: 52, lon: 7.05 }]), edge("lower-in", "branch-in", "lower", [{ lat: 52, lon: 7.01 }, { lat: 51.988, lon: 7.03 }]), edge("lower-out", "lower", "branch-out", [{ lat: 51.988, lon: 7.03 }, { lat: 52, lon: 7.05 }]), edge("destination-access", "branch-out", "destination", [ { lat: 52, lon: 7.05 }, { lat: 52, lon: 7.06 } ]) ] }; const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = { id: "component-aware-snap-test", name: "Komponentenbewusster Snap-Test", maxSnapDistanceNm: 0.3, nodes: [ { id: "start-decoy-a", coordinate: { lat: 53.3416, lon: 7.186 } }, { id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } }, { id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } }, { id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } }, { id: "shared-start", coordinate: { lat: 53.3395697, lon: 7.1848883 } }, { id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } }, { id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } }, { id: "shared-destination", coordinate: { lat: 53.3303531, lon: 6.9334715 } } ], edges: [ edge("start-decoy", "start-decoy-a", "start-decoy-b", [ { lat: 53.3416, lon: 7.186 }, { lat: 53.342, lon: 7.187 } ], { source: "closer-but-disconnected-start" }), edge("destination-decoy", "destination-decoy-a", "destination-decoy-b", [ { lat: 53.3282, lon: 6.9304 }, { lat: 53.3286, lon: 6.9294 } ], { source: "closer-but-disconnected-destination" }), edge("shared-east", "shared-start", "shared-east", [ { lat: 53.3395697, lon: 7.1848883 }, { lat: 53.3321722, lon: 7.1329034 } ], { source: "shared-local-component" }), edge("shared-south", "shared-east", "shared-south", [ { lat: 53.3321722, lon: 7.1329034 }, { lat: 53.313849, lon: 7.0011017 } ], { source: "shared-local-component" }), edge("shared-west", "shared-south", "shared-destination", [ { lat: 53.313849, lon: 7.0011017 }, { lat: 53.3303531, lon: 6.9334715 } ], { source: "shared-local-component" }) ] }; function edge( id: string, from: string, to: string, coordinates: FairwayEdge["coordinates"], restrictions: Partial = {} ): FairwayEdge { return { id, name: id, from, to, coordinates, minDepthM: 4, source: "synthetic-test", ...restrictions }; } function singleEdgeGraph(restrictions: Partial): FairwayGraph { return { id: "restriction-test", name: "Restriktions-Testnetz", maxSnapDistanceNm: 0.2, nodes: [ { id: "start", coordinate: { lat: 52, lon: 7 } }, { id: "destination", coordinate: { lat: 52, lon: 7.04 } } ], edges: [ edge( "restricted", "start", "destination", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.04 }], restrictions ) ] }; } describe("route assessment", () => { it("marks routes with no depth data as unknown", () => { const result = buildManualRoute({ start: { lat: 54.18, lon: 12.08 }, destination: { lat: 54.32, lon: 12.22 }, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 } }); expect(result.unknownDepthRatio).toBe(1); expect(result.warnings.some((warning) => warning.code === "DEPTH_UNKNOWN")).toBe(true); }); it("flags known shallow samples as critical", () => { const result = buildManualRoute({ start: { lat: 54.18, lon: 12.08 }, destination: { lat: 54.32, lon: 12.22 }, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }, depthSamples: [ { coordinate: { lat: 54.2, lon: 12.1 }, depthM: 1.6 }, { coordinate: { lat: 54.25, lon: 12.16 }, depthM: 2.4 } ] }); expect(requiredDepthM({ draughtM: 1.4, safetyReserveM: 0.5 })).toBe(1.9); expect(result.minKnownDepthM).toBe(1.6); expect(result.warnings.some((warning) => warning.severity === "critical")).toBe(true); }); it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => { const result = buildRoute({ start: EMDEN_AUSSENHAFEN, destination: BORKUM_REEDE, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } }); expect(result).not.toBeNull(); if (!result) { throw new Error("Expected fairway route"); } const directDistanceNm = haversineDistanceNm(EMDEN_AUSSENHAFEN, BORKUM_REEDE); const coordinates = result.geometry.coordinates; const largestSegmentNm = coordinates.slice(1).reduce((largest, coordinate, index) => { const previous = coordinates[index]!; return Math.max( largest, haversineDistanceNm( { lon: previous[0], lat: previous[1] }, { lon: coordinate[0], lat: coordinate[1] } ) ); }, 0); expect(result.routingMode).toBe("fairway"); expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed"); expect(result.warnings.some((warning) => warning.code === "FAIRWAY_ROUTE")).toBe(true); expect(result.warnings.some((warning) => warning.code === "MANUAL_ROUTE")).toBe(false); expect(coordinates.length).toBeGreaterThan(20); expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2); expect(largestSegmentNm).toBeLessThan(6); expect(coordinates.some(([lon, lat]) => lon < 6.9 && lat < 53.45)).toBe(true); }); it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => { const clickedStart = { lat: 53.3418, lon: 7.1904 }; const clickedDestination = { lat: 53.5608, lon: 6.7548 }; const result = buildRoute({ start: clickedStart, destination: clickedDestination, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } }); expect(result).not.toBeNull(); if (!result) { throw new Error("Expected fairway route for nearby clicks"); } expect(result.routingMode).toBe("fairway"); expect(result.geometry.coordinates.length).toBeGreaterThan(20); expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]); expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]); expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed"); }); it("uses a shared reachable component when the individually nearest edges are disconnected", () => { const start = { lat: 53.3416, lon: 7.186 }; const destination = { lat: 53.3282, lon: 6.9304 }; const routes = buildFairwayRoutes( { start, destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, COMPONENT_AWARE_SNAP_GRAPH ); expect(routes).toHaveLength(1); expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]); expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]); expect(routes[0]?.dataSources).toContain("shared-local-component"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination"); expect(routes[0]?.distanceNm).toBeGreaterThan(9.5); expect(routes[0]?.distanceNm).toBeLessThan(10.5); }); it("returns no route when start and destination have no shared component inside the snap radius", () => { const disconnectedGraph: FairwayGraph = { ...COMPONENT_AWARE_SNAP_GRAPH, nodes: COMPONENT_AWARE_SNAP_GRAPH.nodes.slice(0, 4), edges: COMPONENT_AWARE_SNAP_GRAPH.edges.slice(0, 2) }; expect( buildFairwayRoute( { start: { lat: 53.3416, lon: 7.186 }, destination: { lat: 53.3282, lon: 6.9304 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, disconnectedGraph ) ).toBeNull(); }); it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => { const graph: FairwayGraph = { id: "oneway-snap-fallback", name: "Einbahnstraßen-Snap-Fallback", maxSnapDistanceNm: 0.2, nodes: [ { id: "junction", coordinate: { lat: 52, lon: 7 } }, { id: "destination", coordinate: { lat: 52, lon: 7.04 } }, { id: "oneway-dead-end", coordinate: { lat: 52.001, lon: 7 } } ], edges: [ edge( "oneway-trap", "junction", "oneway-dead-end", [{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }], { oneway: true, source: "oneway-trap" } ), edge( "main-route", "junction", "destination", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.04 }], { source: "routable-main-edge" } ) ] }; const route = buildFairwayRoute( { start: { lat: 52.001, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); expect(route).not.toBeNull(); expect(route?.dataSources).toContain("routable-main-edge"); expect(route?.dataSources).not.toContain("oneway-trap"); }); it("does not fall back to a misleading straight line when no fairway graph matches", () => { const result = buildRoute({ start: { lat: 54.1749, lon: 12.0731 }, destination: { lat: 54.1833, lon: 12.0928 }, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } }); expect(result).toBeNull(); }); it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => { const result = buildRoute({ start: EMDEN_AUSSENHAFEN, destination: HAMM_INNENSTADT_MARINA, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } }); expect(result).not.toBeNull(); expect(result?.distanceNm).toBeGreaterThan(145); expect(result?.distanceNm).toBeLessThan(165); expect(result?.geometry.coordinates.length).toBeGreaterThan(350); expect(result?.dataSources).toContain("fairway-graph:emden-hamm-inland-seed"); expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed"); expect(result?.dataSources).toContain("openstreetmap-nominatim-curated-seed"); expect(result?.warnings.some((warning) => warning.code === "FAIRWAY_DATA_NOT_OFFICIAL")).toBe(true); }); it("uses the requested departure time as the basis for duration and ETA", () => { const departureTime = "2026-07-20T04:15:00.000Z"; const result = buildFairwayRoute( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.04 }, departureTime, vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 } }, singleEdgeGraph({}) ); expect(result).not.toBeNull(); expect(result?.departureTime).toBe(departureTime); expect(result?.durationMinutes).toBeGreaterThan(0); expect(result?.eta).toBe( new Date(Date.parse(departureTime) + (result?.durationMinutes ?? 0) * 60_000).toISOString() ); }); it("returns a shortest route plus two genuinely different alternatives", () => { const routes = buildFairwayRoutes( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.06 }, vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 } }, ALTERNATIVE_GRAPH ); expect(routes).toHaveLength(3); expect(routes.map((route) => route.id)).toEqual([ "alternative-test-route-1", "alternative-test-route-2", "alternative-test-route-3" ]); expect(routes.map((route) => route.name)).toEqual(["Hauptroute", "Alternative 1", "Alternative 2"]); expect(routes[0]!.distanceNm).toBeLessThan(routes[1]!.distanceNm); expect(new Set(routes.map((route) => JSON.stringify(route.geometry.coordinates))).size).toBe(3); expect(buildFairwayRoutes( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.06 }, vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 } }, ALTERNATIVE_GRAPH, 1 )).toHaveLength(1); }); it("filters edges that violate air-draft, beam, or maximum-draught restrictions", () => { const baseRequest = { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.3, airDraftM: 3, beamM: 3 } }; expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxAirDraftM: 2.5 }))).toBeNull(); expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxBeamM: 2.5 }))).toBeNull(); expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxDraughtM: 1.2 }))).toBeNull(); expect( buildFairwayRoute(baseRequest, singleEdgeGraph({ maxAirDraftM: 3, maxBeamM: 3, maxDraughtM: 1.4 })) ).not.toBeNull(); }); it("uses an unrestricted detour when the shorter edge is too low for the vessel", () => { const graph: FairwayGraph = { ...ALTERNATIVE_GRAPH, edges: ALTERNATIVE_GRAPH.edges.map((candidate) => candidate.id === "main" ? { ...candidate, maxAirDraftM: 2 } : candidate ) }; const route = buildFairwayRoute( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.06 }, vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, airDraftM: 2.5 } }, graph ); expect(route).not.toBeNull(); expect(route?.geometry.coordinates.some(([, lat]) => lat !== 52)).toBe(true); }); it("honours one-way fairway edges for direct and reverse travel", () => { const graph = singleEdgeGraph({ oneway: true }); const profile = { draughtM: 1.2, safetyReserveM: 0.3 }; expect( buildFairwayRoute( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: profile }, graph ) ).not.toBeNull(); expect( buildFairwayRoute( { start: { lat: 52, lon: 7.04 }, destination: { lat: 52, lon: 7 }, vesselProfile: profile }, graph ) ).toBeNull(); }); });