Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
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 }
|
||||
])
|
||||
]
|
||||
};
|
||||
|
||||
function edge(
|
||||
id: string,
|
||||
from: string,
|
||||
to: string,
|
||||
coordinates: FairwayEdge["coordinates"],
|
||||
restrictions: Partial<FairwayEdge> = {}
|
||||
): FairwayEdge {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
from,
|
||||
to,
|
||||
coordinates,
|
||||
minDepthM: 4,
|
||||
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 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("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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user