import { describe, expect, it } from "vitest"; import { buildFairwayRoute, buildFairwayRoutes, buildManualRoute, buildRoute, FairwayRoutingSearchLimitError, haversineDistanceNm, 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 REMOVED_AUTOMATIC_ROUTE_WARNING_CODES = new Set([ "FAIRWAY_ROUTE", "FAIRWAY_DATA_NOT_OFFICIAL", "DEPTH_UNKNOWN", "DEPTH_PARTIAL", "NO_KNOWN_DEPTH", "DEPTH_TOO_SHALLOW" ]); 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.34135, lon: 7.186 } }, { 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.32845, lon: 6.9304 } } ], 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.34135, lon: 7.186 }, { 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.32845, lon: 6.9304 } ], { 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, 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 building", () => { it("builds manual routes without synthesizing a depth status", () => { 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.warnings.map((warning) => warning.code)).toEqual(["MANUAL_ROUTE"]); expect(result).not.toHaveProperty("minKnownDepthM"); expect(result).not.toHaveProperty("unknownDepthRatio"); }); 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 } }, { allowDemoSeedGraphs: true } ); 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 === "MANUAL_ROUTE")).toBe(false); expect( result.warnings.some((warning) => REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code) ) ).toBe(false); expect(result).not.toHaveProperty("minKnownDepthM"); expect(result).not.toHaveProperty("unknownDepthRatio"); 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.341276, lon: 7.189146 }; const clickedDestination = { lat: 53.560625, lon: 6.751271 }; const result = buildRoute( { start: clickedStart, destination: clickedDestination, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } }, { allowDemoSeedGraphs: true } ); 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.routeSnaps?.start.requested).toEqual(clickedStart); expect(result.routeSnaps?.destination.requested).toEqual(clickedDestination); expect(result.routeSnaps?.start.distanceM).toBeGreaterThan(0); expect(result.routeSnaps?.destination.distanceM).toBeGreaterThan(0); expect( result.warnings.some((warning) => warning.code === "ROUTE_POINT_SNAPPED") ).toBe(true); expect(result.geometry.coordinates[0]).toEqual([ result.routeSnaps?.start.snapped.lon, result.routeSnaps?.start.snapped.lat ]); expect(result.geometry.coordinates.at(-1)).toEqual([ result.routeSnaps?.destination.snapped.lon, result.routeSnaps?.destination.snapped.lat ]); expect(result.geometry.coordinates[0]).not.toEqual([clickedStart.lon, clickedStart.lat]); expect(result.geometry.coordinates.at(-1)).not.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([ routes[0]?.routeSnaps?.start.snapped.lon, routes[0]?.routeSnaps?.start.snapped.lat ]); expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([ routes[0]?.routeSnaps?.destination.snapped.lon, routes[0]?.routeSnaps?.destination.snapped.lat ]); expect(routes[0]?.routeSnaps?.start.requested).toEqual(start); expect(routes[0]?.routeSnaps?.destination.requested).toEqual(destination); 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.3); expect(routes[0]?.distanceNm).toBeLessThan(9.7); }); 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("accepts a nearby harbour click but hard-rejects snaps farther than 150 metres", () => { const graph: FairwayGraph = { ...singleEdgeGraph({}), maxSnapDistanceNm: 2 }; const nearbyRoute = buildFairwayRoute( { start: { lat: 52 + 140 / 1852 / 60, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); const remoteRoute = buildFairwayRoute( { start: { lat: 52 + 160 / 1852 / 60, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); expect(nearbyRoute?.routeSnaps?.start.distanceM).toBeCloseTo(140, 0); expect(nearbyRoute?.geometry.coordinates[0]).toEqual([7, 52]); expect( nearbyRoute?.warnings.find((warning) => warning.code === "ROUTE_POINT_SNAPPED") ).toMatchObject({ severity: "caution" }); expect(remoteRoute).toBeNull(); }); it("uses one graph snap for an intermediate waypoint without drawing raw access segments", () => { const waypoint = { lat: 52.00025, lon: 7.02 }; const graph: FairwayGraph = { id: "waypoint-snap-test", name: "Wegpunkt-Snap-Test", maxSnapDistanceNm: 0.2, nodes: [ { id: "a", coordinate: { lat: 52, lon: 7 } }, { id: "b", coordinate: { lat: 52, lon: 7.02 } }, { id: "c", coordinate: { lat: 52, lon: 7.04 } } ], edges: [ edge("ab", "a", "b", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.02 }]), edge("bc", "b", "c", [{ lat: 52, lon: 7.02 }, { lat: 52, lon: 7.04 }]) ] }; const route = buildFairwayRoute( { start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.04 }, waypoints: [waypoint], vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); expect(route).not.toBeNull(); expect(route?.routeSnaps?.waypoints).toHaveLength(1); expect(route?.routeSnaps?.waypoints[0]).toMatchObject({ requested: waypoint, snapped: { lat: 52, lon: 7.02 } }); expect(route?.geometry.coordinates).not.toContainEqual([waypoint.lon, waypoint.lat]); expect(route?.geometry.coordinates).toContainEqual([7.02, 52]); }); 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.00035, lon: 7 } } ], edges: [ edge( "oneway-trap", "junction", "oneway-dead-end", [{ lat: 52, lon: 7 }, { lat: 52.00035, 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.00035, 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("keeps searching when only the fifth-nearest snap in a component is directionally routable", () => { const start = { lat: 52.00035, lon: 7.0005 }; const graph: FairwayGraph = { id: "fifth-directed-snap", name: "Adaptiver gerichteter Snap-Test", maxSnapDistanceNm: 1, nodes: [ { id: "junction", coordinate: { lat: 52, lon: 7 } }, { id: "trap-1", coordinate: start }, { id: "trap-2", coordinate: { lat: 52.00036, lon: 7.0005 } }, { id: "trap-3", coordinate: { lat: 52.00034, lon: 7.0005 } }, { id: "trap-4", coordinate: { lat: 52.00035, lon: 7.00052 } }, { id: "destination", coordinate: { lat: 52, lon: 7.04 } } ], edges: [ edge("trap-1", "junction", "trap-1", [{ lat: 52, lon: 7 }, start], { oneway: true, source: "oneway-trap-1" }), edge("trap-2", "junction", "trap-2", [ { lat: 52, lon: 7 }, { lat: 52.00036, lon: 7.0005 } ], { oneway: true, source: "oneway-trap-2" }), edge("trap-3", "junction", "trap-3", [ { lat: 52, lon: 7 }, { lat: 52.00034, lon: 7.0005 } ], { oneway: true, source: "oneway-trap-3" }), edge("trap-4", "junction", "trap-4", [ { lat: 52, lon: 7 }, { lat: 52.00035, lon: 7.00052 } ], { oneway: true, source: "oneway-trap-4" }), edge("main-route", "junction", "destination", [ { lat: 52, lon: 7 }, { lat: 52, lon: 7.04 } ], { source: "fifth-routable-edge" }) ] }; const route = buildFairwayRoute( { start, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); expect(route).not.toBeNull(); expect(route?.routeSnaps?.start.distanceM).toBeGreaterThan(30); expect(route?.routeSnaps?.start.distanceM).toBeLessThan(50); expect(route?.dataSources).toContain("fifth-routable-edge"); expect(route?.dataSources.some((source) => source.startsWith("oneway-trap"))).toBe(false); }); it("prefers a much shorter route through the fifth-nearest snap", () => { const start = { lat: 52, lon: 7 }; const offsetLat = (metres: number) => metres / 1852 / 60; const longEntry = { lat: 52.08, lon: 7 }; const join = { lat: 52, lon: 7.08 }; const destination = { lat: 52, lon: 7.09 }; const trapStarts = [0, 1, 2, 3].map((metres) => ({ lat: start.lat + offsetLat(metres), lon: start.lon })); const shortStart = { lat: start.lat + offsetLat(9), lon: start.lon }; const graph: FairwayGraph = { id: "fifth-shorter-snap", name: "Kostenbewusster Snap-Test", maxSnapDistanceNm: 1, nodes: [ ...trapStarts.map((coordinate, index) => ({ id: `trap-start-${index + 1}`, coordinate })), { id: "short-start", coordinate: shortStart }, { id: "long-entry", coordinate: longEntry }, { id: "join", coordinate: join }, { id: "destination", coordinate: destination } ], edges: [ ...trapStarts.map((coordinate, index) => edge( `long-access-${index + 1}`, `trap-start-${index + 1}`, "long-entry", [coordinate, longEntry], { oneway: true, source: `long-access-${index + 1}` } ) ), edge("long-detour", "long-entry", "join", [ longEntry, { lat: 52.08, lon: 7.08 }, join ], { oneway: true, source: "long-detour" }), edge("short-fifth", "short-start", "join", [ shortStart, join ], { oneway: true, source: "short-fifth" }), edge("destination-tail", "join", "destination", [ join, destination ], { oneway: true, source: "destination-tail" }) ] }; const route = buildFairwayRoute( { start, destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, graph ); expect(route).not.toBeNull(); expect(route?.distanceNm).toBeLessThan(5); expect(route?.routeSnaps?.start.distanceM).toBeCloseTo(9, 0); expect(route?.dataSources).toContain("short-fifth"); expect(route?.dataSources).not.toContain("long-detour"); }); it("still routes when a bounded snap search found a connected path", () => { const start = { lat: 52.00035, lon: 7.0005 }; const mainStart = { lat: 52, lon: 7.0005 }; const destination = { lat: 52, lon: 7.04 }; const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({ lat: start.lat + (index - 4) * 0.000001, lon: start.lon })); const graph: FairwayGraph = { id: "bounded-snap-search", name: "Begrenzter Snap-Suchtest", maxSnapDistanceNm: 1, nodes: [ { id: "junction", coordinate: { lat: 52, lon: 7 } }, ...trapCoordinates.map((coordinate, index) => ({ id: `trap-${index + 1}`, coordinate })), { id: "destination", coordinate: destination } ], edges: [ ...trapCoordinates.map((coordinate, index) => edge( `trap-${index + 1}`, "junction", `trap-${index + 1}`, [{ lat: 52, lon: 7 }, coordinate], { oneway: true, source: `bounded-trap-${index + 1}` } ) ), edge("ninth-main-route", "junction", "destination", [ { lat: 52, lon: 7 }, destination ], { oneway: true, source: "ninth-main-route" }) ] }; const request = { start, destination, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }; expect(() => buildFairwayRoute(request, graph)).toThrow( FairwayRoutingSearchLimitError ); const boundedRoute = buildFairwayRoute( { ...request, start: mainStart }, graph ); expect(boundedRoute).not.toBeNull(); expect( boundedRoute?.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED") ).toBe(false); }); 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("does not silently use demo seed graphs when no routing source is provided", () => { const request = { start: EMDEN_AUSSENHAFEN, destination: BORKUM_REEDE, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } }; const result = buildRoute(request); expect(result).toBeNull(); const buildWithoutGraph = buildFairwayRoute as unknown as ( routeRequest: typeof request ) => ReturnType; const buildManyWithoutGraph = buildFairwayRoutes as unknown as ( routeRequest: typeof request ) => ReturnType; expect(buildWithoutGraph(request)).toBeNull(); expect(buildManyWithoutGraph(request)).toEqual([]); }); 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 } }, { allowDemoSeedGraphs: true } ); 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) => REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code) ) ).toBe(false); }); 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(); }); });