721 lines
25 KiB
TypeScript
721 lines
25 KiB
TypeScript
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> = {}
|
|
): FairwayEdge {
|
|
return {
|
|
id,
|
|
name: id,
|
|
from,
|
|
to,
|
|
coordinates,
|
|
source: "synthetic-test",
|
|
...restrictions
|
|
};
|
|
}
|
|
|
|
function singleEdgeGraph(restrictions: Partial<FairwayEdge>): 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("reports bounded snap-search truncation instead of silently claiming no route", () => {
|
|
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(true);
|
|
});
|
|
|
|
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<typeof buildFairwayRoute>;
|
|
const buildManyWithoutGraph = buildFairwayRoutes as unknown as (
|
|
routeRequest: typeof request
|
|
) => ReturnType<typeof buildFairwayRoutes>;
|
|
|
|
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();
|
|
});
|
|
});
|