Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
analyzeTideWindow,
|
||||
calculateAnchorRodePlan,
|
||||
evaluateAnchorWatch,
|
||||
type AnchorTideWindowResult,
|
||||
type TideCurvePoint,
|
||||
type TideSummary
|
||||
} from "../src/index.js";
|
||||
|
||||
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
|
||||
const START = Date.parse("2026-07-20T01:00:00.000Z");
|
||||
|
||||
function latitudeOffsetM(metres: number): number {
|
||||
return metres / METRES_PER_EQUATOR_DEGREE;
|
||||
}
|
||||
|
||||
function curve(): TideCurvePoint[] {
|
||||
return [
|
||||
{ time: "2026-07-20T00:00:00.000Z", predictedM: 1 },
|
||||
{
|
||||
time: "2026-07-20T02:00:00.000Z",
|
||||
predictedM: 2,
|
||||
forecastM: 2.5,
|
||||
measuredM: 3
|
||||
},
|
||||
{ time: "2026-07-20T04:00:00.000Z", predictedM: 0 },
|
||||
{ time: "2026-07-20T06:00:00.000Z", predictedM: 2 }
|
||||
];
|
||||
}
|
||||
|
||||
function completeTide(maximumRiseM = 1): AnchorTideWindowResult {
|
||||
return {
|
||||
coverage: "complete",
|
||||
reason: null,
|
||||
fromTime: "2026-07-20T00:00:00.000Z",
|
||||
untilTime: "2026-07-20T06:00:00.000Z",
|
||||
coveredUntilTime: "2026-07-20T06:00:00.000Z",
|
||||
horizonHours: 6,
|
||||
startHeightM: 0,
|
||||
minimumHeightM: 0,
|
||||
maximumHeightM: maximumRiseM,
|
||||
maximumRiseM,
|
||||
tidalRangeM: maximumRiseM,
|
||||
sampleCount: 3
|
||||
};
|
||||
}
|
||||
|
||||
describe("anchor watch", () => {
|
||||
it("alarms only when the entire GPS accuracy circle is outside the anchor radius", () => {
|
||||
const result = evaluateAnchorWatch({
|
||||
anchorPoint: { lat: 0, lon: 0 },
|
||||
position: { lat: latitudeOffsetM(150), lon: 0 },
|
||||
alarmRadiusM: 100,
|
||||
accuracyM: 20,
|
||||
maxReliableAccuracyM: 50
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.distanceFromAnchorM).toBeCloseTo(150, 0);
|
||||
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(130, 0);
|
||||
expect(result?.positionReliable).toBe(true);
|
||||
expect(result?.isOutsideAlarmRadius).toBe(true);
|
||||
expect(result?.isConservativelyOutsideAlarmRadius).toBe(true);
|
||||
expect(result?.alarmTriggered).toBe(true);
|
||||
expect(result?.status).toBe("alarm");
|
||||
});
|
||||
|
||||
it("does not alarm while GPS uncertainty still overlaps the permitted circle", () => {
|
||||
const result = evaluateAnchorWatch({
|
||||
anchorPoint: { lat: 0, lon: 0 },
|
||||
position: { lat: latitudeOffsetM(110), lon: 0 },
|
||||
alarmRadiusM: 100,
|
||||
accuracyM: 20
|
||||
});
|
||||
|
||||
expect(result?.isOutsideAlarmRadius).toBe(true);
|
||||
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(90, 0);
|
||||
expect(result?.isConservativelyOutsideAlarmRadius).toBe(false);
|
||||
expect(result?.alarmTriggered).toBe(false);
|
||||
expect(result?.status).toBe("safe");
|
||||
});
|
||||
|
||||
it("suppresses alarms for inaccurate or missing accuracy information", () => {
|
||||
const inaccurate = evaluateAnchorWatch({
|
||||
anchorPoint: { lat: 0, lon: 0 },
|
||||
position: { lat: latitudeOffsetM(400), lon: 0 },
|
||||
alarmRadiusM: 100,
|
||||
accuracyM: 150,
|
||||
maxReliableAccuracyM: 100
|
||||
});
|
||||
const missing = evaluateAnchorWatch({
|
||||
anchorPoint: { lat: 0, lon: 0 },
|
||||
position: { lat: latitudeOffsetM(400), lon: 0 },
|
||||
alarmRadiusM: 100
|
||||
});
|
||||
|
||||
expect(inaccurate?.isConservativelyOutsideAlarmRadius).toBe(true);
|
||||
expect(inaccurate?.positionReliable).toBe(false);
|
||||
expect(inaccurate?.alarmTriggered).toBe(false);
|
||||
expect(inaccurate?.status).toBe("gps-unreliable");
|
||||
expect(missing?.conservativeDistanceFromAnchorM).toBeNull();
|
||||
expect(missing?.positionReliable).toBe(false);
|
||||
expect(missing?.alarmTriggered).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid watch geometry and configuration without throwing", () => {
|
||||
expect(evaluateAnchorWatch({
|
||||
anchorPoint: { lat: Number.NaN, lon: 7 },
|
||||
position: { lat: 53, lon: 7 },
|
||||
alarmRadiusM: 100,
|
||||
accuracyM: 10
|
||||
})).toBeNull();
|
||||
expect(evaluateAnchorWatch({
|
||||
anchorPoint: { lat: 53, lon: 7 },
|
||||
position: { lat: 53, lon: 7 },
|
||||
alarmRadiusM: 0,
|
||||
accuracyM: 10
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor tide window", () => {
|
||||
it("interpolates both window boundaries and calculates rise and tidal range", () => {
|
||||
const result = analyzeTideWindow(curve(), START, 4);
|
||||
|
||||
expect(result.coverage).toBe("complete");
|
||||
expect(result.reason).toBeNull();
|
||||
expect(result.startHeightM).toBeCloseTo(2, 6);
|
||||
expect(result.minimumHeightM).toBe(0);
|
||||
expect(result.maximumHeightM).toBe(3);
|
||||
expect(result.maximumRiseM).toBe(1);
|
||||
expect(result.tidalRangeM).toBe(3);
|
||||
expect(result.coveredUntilTime).toBe("2026-07-20T05:00:00.000Z");
|
||||
expect(result.sampleCount).toBe(4);
|
||||
});
|
||||
|
||||
it("accepts a TideSummary and prioritises measurements over forecasts and predictions", () => {
|
||||
const summary: TideSummary = {
|
||||
station: "Test",
|
||||
distanceKm: 1,
|
||||
nextHigh: null,
|
||||
nextLow: null,
|
||||
waterLevelCurve: curve(),
|
||||
source: "test",
|
||||
updatedAt: "2026-07-20T00:00:00.000Z"
|
||||
};
|
||||
const result = analyzeTideWindow(summary, Date.parse("2026-07-20T02:00:00Z"), 1);
|
||||
|
||||
expect(result.startHeightM).toBe(3);
|
||||
expect(result.maximumHeightM).toBe(3);
|
||||
});
|
||||
|
||||
it("reports partial coverage and still describes only the known part", () => {
|
||||
const result = analyzeTideWindow(curve(), START, 8);
|
||||
|
||||
expect(result.coverage).toBe("partial");
|
||||
expect(result.reason).toBe("incomplete-horizon");
|
||||
expect(result.untilTime).toBe("2026-07-20T09:00:00.000Z");
|
||||
expect(result.coveredUntilTime).toBe("2026-07-20T06:00:00.000Z");
|
||||
expect(result.maximumRiseM).toBe(1);
|
||||
expect(result.tidalRangeM).toBe(3);
|
||||
});
|
||||
|
||||
it("handles missing, malformed, and out-of-coverage tide data", () => {
|
||||
expect(analyzeTideWindow(undefined, START, 6)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
|
||||
);
|
||||
expect(analyzeTideWindow([
|
||||
{ time: "not-a-time", predictedM: 2 },
|
||||
{ time: "2026-07-20T00:00:00Z", predictedM: null }
|
||||
], START, 6)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
|
||||
);
|
||||
expect(analyzeTideWindow(curve(), Date.parse("2026-07-19T20:00:00Z"), 2)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "start-outside-coverage" })
|
||||
);
|
||||
expect(analyzeTideWindow(curve(), Number.NaN, 6)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
|
||||
);
|
||||
expect(analyzeTideWindow(curve(), Number.MAX_VALUE, 6)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window", fromTime: null })
|
||||
);
|
||||
expect(analyzeTideWindow(curve(), START, 0)).toEqual(
|
||||
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
|
||||
);
|
||||
});
|
||||
|
||||
it("never treats a falling tide as a negative future rise", () => {
|
||||
const falling = analyzeTideWindow([
|
||||
{ time: "2026-07-20T00:00:00Z", predictedM: 3 },
|
||||
{ time: "2026-07-20T02:00:00Z", predictedM: 2 },
|
||||
{ time: "2026-07-20T04:00:00Z", predictedM: 1 }
|
||||
], Date.parse("2026-07-20T00:00:00Z"), 4);
|
||||
|
||||
expect(falling.coverage).toBe("complete");
|
||||
expect(falling.maximumRiseM).toBe(0);
|
||||
expect(falling.tidalRangeM).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor rode plan", () => {
|
||||
it("includes tide rise, bow roller, safety allowance, and selected scope", () => {
|
||||
const plan = calculateAnchorRodePlan({
|
||||
depthAtSetM: 4,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 40,
|
||||
scopeRatio: 5,
|
||||
safetyAllowanceM: 0.5,
|
||||
tideWindow: completeTide(1)
|
||||
});
|
||||
|
||||
expect(plan).not.toBeNull();
|
||||
expect(plan?.calculationComplete).toBe(true);
|
||||
expect(plan?.verticalDistanceAtSetM).toBe(5);
|
||||
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
|
||||
expect(plan?.maximumVerticalDistanceM).toBe(6);
|
||||
expect(plan?.planningVerticalDistanceM).toBe(6.5);
|
||||
expect(plan?.requiredRodeLengthM).toBe(32.5);
|
||||
expect(plan?.rodeReserveM).toBe(7.5);
|
||||
expect(plan?.hasSufficientRode).toBe(true);
|
||||
expect(plan?.horizontalReachM).toBeCloseTo(Math.sqrt(40 ** 2 - 6 ** 2), 6);
|
||||
expect(plan?.rodeReachesBottomAtMaximumTide).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a negative reserve and insufficient rode", () => {
|
||||
const plan = calculateAnchorRodePlan({
|
||||
depthAtSetM: 4,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 30,
|
||||
scopeRatio: 5,
|
||||
safetyAllowanceM: 0.5,
|
||||
tideWindow: completeTide(1)
|
||||
});
|
||||
|
||||
expect(plan?.requiredRodeLengthM).toBe(32.5);
|
||||
expect(plan?.rodeReserveM).toBe(-2.5);
|
||||
expect(plan?.hasSufficientRode).toBe(false);
|
||||
});
|
||||
|
||||
it("does not present an incomplete tide horizon as a safe future plan", () => {
|
||||
const partialTide = { ...completeTide(1), coverage: "partial" as const, reason: "incomplete-horizon" as const };
|
||||
const plan = calculateAnchorRodePlan({
|
||||
depthAtSetM: 4,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 40,
|
||||
scopeRatio: 5,
|
||||
tideWindow: partialTide
|
||||
});
|
||||
|
||||
expect(plan?.calculationComplete).toBe(false);
|
||||
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
|
||||
expect(plan?.requiredRodeLengthM).toBeNull();
|
||||
expect(plan?.rodeReserveM).toBeNull();
|
||||
expect(plan?.hasSufficientRode).toBeNull();
|
||||
expect(plan?.horizontalReachAtSetM).toBeCloseTo(Math.sqrt(40 ** 2 - 5 ** 2), 6);
|
||||
expect(plan?.horizontalReachM).toBeNull();
|
||||
});
|
||||
|
||||
it("validates physical inputs and handles a rode shorter than the vertical drop", () => {
|
||||
expect(calculateAnchorRodePlan({
|
||||
depthAtSetM: -1,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 20,
|
||||
scopeRatio: 5,
|
||||
tideWindow: completeTide()
|
||||
})).toBeNull();
|
||||
expect(calculateAnchorRodePlan({
|
||||
depthAtSetM: 4,
|
||||
bowRollerHeightM: 1,
|
||||
deployedRodeLengthM: 20,
|
||||
scopeRatio: 0,
|
||||
tideWindow: completeTide()
|
||||
})).toBeNull();
|
||||
|
||||
const tooShort = calculateAnchorRodePlan({
|
||||
depthAtSetM: 8,
|
||||
bowRollerHeightM: 2,
|
||||
deployedRodeLengthM: 9,
|
||||
scopeRatio: 3,
|
||||
tideWindow: completeTide(1)
|
||||
});
|
||||
expect(tooShort?.horizontalReachAtSetM).toBe(0);
|
||||
expect(tooShort?.horizontalReachM).toBe(0);
|
||||
expect(tooShort?.rodeReachesBottomAtSet).toBe(false);
|
||||
expect(tooShort?.rodeReachesBottomAtMaximumTide).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { haversineDistanceNm, initialBearingDeg, normalizeHeadingDeg } from "../src/index.js";
|
||||
|
||||
describe("geo helpers", () => {
|
||||
it("calculates a practical nautical-mile distance", () => {
|
||||
const distance = haversineDistanceNm(
|
||||
{ lat: 53.541, lon: 9.966 },
|
||||
{ lat: 54.18, lon: 12.09 }
|
||||
);
|
||||
|
||||
expect(distance).toBeGreaterThan(80);
|
||||
expect(distance).toBeLessThan(95);
|
||||
});
|
||||
|
||||
it("normalizes headings and bearings", () => {
|
||||
expect(normalizeHeadingDeg(-10)).toBe(350);
|
||||
expect(normalizeHeadingDeg(370)).toBe(10);
|
||||
expect(initialBearingDeg({ lat: 53.5, lon: 9.9 }, { lat: 54, lon: 10.1 })).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalizeMarinePois,
|
||||
normalizedMarineFacilityName,
|
||||
type MarinePoiCandidate
|
||||
} from "../src/marine-poi-clustering.js";
|
||||
|
||||
const lock = (
|
||||
id: string,
|
||||
name: string | null,
|
||||
lon: number,
|
||||
properties: Record<string, unknown> = {},
|
||||
overrides: Partial<MarinePoiCandidate> = {}
|
||||
): MarinePoiCandidate => ({
|
||||
id,
|
||||
layer: "locks",
|
||||
source: "osm",
|
||||
sourceId: id,
|
||||
name,
|
||||
coordinate: { lon, lat: 51.695 },
|
||||
properties,
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe("marine POI canonicalization", () => {
|
||||
it("normalizes facility type words but retains facility ordinals", () => {
|
||||
expect(normalizedMarineFacilityName("Schleuse Werries")).toBe("werries");
|
||||
expect(normalizedMarineFacilityName("Schleusengebiet Werries")).toBe("werries");
|
||||
expect(normalizedMarineFacilityName("Werries Lock")).toBe("werries");
|
||||
expect(normalizedMarineFacilityName("W.S.V. Sixhaven")).toBe("sixhaven");
|
||||
expect(normalizedMarineFacilityName("Große Kammer Schleuse Ahsen")).toBe("ahsen");
|
||||
expect(normalizedMarineFacilityName("Schleuse Ahsen Kammer 2")).toBe("ahsen");
|
||||
expect(normalizedMarineFacilityName("Schleuse 55")).toBe("55");
|
||||
expect(normalizedMarineFacilityName("Außentor")).toBeNull();
|
||||
});
|
||||
|
||||
it("merges matching OSM and EuRIS locks and combines their contacts", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("w-osm", "Schleuse Werries", 7.867, {
|
||||
website: "https://osm.example/werries",
|
||||
phone: "+49 2381 9019290"
|
||||
}),
|
||||
lock(
|
||||
"w-euris",
|
||||
"Werries",
|
||||
7.86708,
|
||||
{ "ref:EU:RIS": "DEHMM00301LOCKS00404", phone: "+49 2381 9019-290", vhf: "22" },
|
||||
{ source: "euris", sourceId: "DEHMM00301LOCKS00404" }
|
||||
)
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
entityId: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
|
||||
canonicalSource: "euris",
|
||||
memberCount: 2,
|
||||
properties: {
|
||||
phone: "+49 2381 9019-290",
|
||||
website: "https://osm.example/werries",
|
||||
vhf: "22",
|
||||
source: "EuRIS + OpenStreetMap"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge neighbouring facilities with different specific names", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("a", "Wilhelminasluis", 4.724),
|
||||
lock("b", "Hondsbossche Sluis", 4.72418)
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("merges duplicate facilities with the exact same name despite differing source websites", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("relation", "Kesselschleuse Emden", 7.2, { website: "https://city.example/lock" }),
|
||||
lock("way", "Kesselschleuse Emden", 7.20008, { website: "https://operator.example/lock" })
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.properties.websites).toEqual([
|
||||
"https://city.example/lock",
|
||||
"https://operator.example/lock"
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not merge equal names carrying conflicting official IDs", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("a", "Schleuse 55", 7.1, { "ref:EU:RIS": "DE-55" }),
|
||||
lock("b", "Schleuse 55", 7.1001, { "ref:EU:RIS": "DE-56" })
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("west", "Schleuse West", 7),
|
||||
lock("east", "Schleuse Ost", 7.0018),
|
||||
lock("gate", "Außentor", 7.0002, { waterway: "lock_gate" })
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.find((poi) => poi.name === "Schleuse West")?.memberIds).toContain("gate");
|
||||
expect(result.find((poi) => poi.name === "Schleuse Ost")?.memberIds).not.toContain("gate");
|
||||
});
|
||||
|
||||
it("groups a named harbour anchor with its dock component", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
{
|
||||
id: "marina",
|
||||
layer: "harbours",
|
||||
source: "osm",
|
||||
sourceId: "w1",
|
||||
name: "Stadthafen",
|
||||
coordinate: { lon: 7.7, lat: 52 },
|
||||
properties: { leisure: "marina" }
|
||||
},
|
||||
{
|
||||
id: "dock",
|
||||
layer: "harbours",
|
||||
source: "osm",
|
||||
sourceId: "w2",
|
||||
name: "Stadthafen",
|
||||
coordinate: { lon: 7.7004, lat: 52 },
|
||||
properties: { waterway: "dock" }
|
||||
}
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.memberCount).toBe(2);
|
||||
});
|
||||
|
||||
it("is independent of input order and adding a gate keeps the anchor ID stable", () => {
|
||||
const anchor = lock("anchor", "Kesselschleuse Emden", 7.2, { lock: "yes" });
|
||||
const gate = lock("gate", null, 7.2001, { waterway: "lock_gate" });
|
||||
const forward = canonicalizeMarinePois([anchor, gate]);
|
||||
const reverse = canonicalizeMarinePois([gate, anchor]);
|
||||
const anchorOnly = canonicalizeMarinePois([anchor]);
|
||||
expect(forward).toEqual(reverse);
|
||||
expect(forward[0]?.entityId).toBe(anchorOnly[0]?.entityId);
|
||||
});
|
||||
|
||||
it("keeps same-named facilities apart beyond the layer radius", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("one", "Keersluis", 5),
|
||||
lock("two", "Keersluis", 5.02)
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateRouteGuidance,
|
||||
type GeoJsonLineString,
|
||||
type RouteGuidanceRoute
|
||||
} from "../src/index.js";
|
||||
|
||||
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
|
||||
|
||||
function latitudeOffsetM(metres: number): number {
|
||||
return metres / METRES_PER_EQUATOR_DEGREE;
|
||||
}
|
||||
|
||||
function requireGuidance(
|
||||
route: RouteGuidanceRoute,
|
||||
position = { lat: 0, lon: 0 },
|
||||
input: Partial<Parameters<typeof calculateRouteGuidance>[0]> = {},
|
||||
options: Parameters<typeof calculateRouteGuidance>[1] = {}
|
||||
) {
|
||||
const result = calculateRouteGuidance({ route, position, ...input }, options);
|
||||
expect(result).not.toBeNull();
|
||||
if (!result) {
|
||||
throw new Error("Expected valid route guidance");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("route guidance", () => {
|
||||
it("projects a GPS fix geodesically and steers back towards the route", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.02, 0]],
|
||||
{ lat: latitudeOffsetM(100), lon: 0.005 },
|
||||
{ headingDeg: 90, accuracyM: 5 },
|
||||
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
|
||||
);
|
||||
|
||||
expect(result.distanceToRouteM).toBeCloseTo(100, 0);
|
||||
expect(result.crossTrackErrorM).toBeCloseTo(-100, 0);
|
||||
expect(result.crossTrackSide).toBe("port");
|
||||
expect(result.nearestRoutePoint.lat).toBeCloseTo(0, 6);
|
||||
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.005, 5);
|
||||
expect(result.desiredCourseDeg).toBeGreaterThan(90);
|
||||
expect(result.desiredCourseDeg).toBeLessThan(180);
|
||||
expect(result.courseCorrectionDeg).toBeGreaterThan(0);
|
||||
expect(result.status).toBe("on-route");
|
||||
});
|
||||
|
||||
it("adapts the interception course continuously to either side of the line", () => {
|
||||
const route = [[0, 0], [0.02, 0]] as const;
|
||||
const north = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(50), lon: 0.005 },
|
||||
{},
|
||||
{ minLookaheadM: 150, maxLookaheadM: 150 }
|
||||
);
|
||||
const south = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(-50), lon: 0.005 },
|
||||
{},
|
||||
{ minLookaheadM: 150, maxLookaheadM: 150 }
|
||||
);
|
||||
|
||||
expect(north.desiredCourseDeg).toBeGreaterThan(90);
|
||||
expect(south.desiredCourseDeg).toBeLessThan(90);
|
||||
expect(north.crossTrackSide).toBe("port");
|
||||
expect(south.crossTrackSide).toBe("starboard");
|
||||
});
|
||||
|
||||
it("uses speed and GPS accuracy for lookahead and respects its upper bound", () => {
|
||||
const route = [[0, 0], [0.2, 0]] as const;
|
||||
const stationary = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 0, accuracyM: 0 });
|
||||
const moving = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 10, accuracyM: 20 });
|
||||
const capped = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 100, accuracyM: 20 });
|
||||
|
||||
expect(stationary.lookaheadDistanceM).toBeCloseTo(50, 6);
|
||||
expect(moving.lookaheadDistanceM).toBeGreaterThan(stationary.lookaheadDistanceM);
|
||||
expect(moving.lookaheadDistanceM).toBeCloseTo(182.89, 1);
|
||||
expect(capped.lookaheadDistanceM).toBe(400);
|
||||
});
|
||||
|
||||
it("caps its target at a significant bend instead of cutting across a canal corner", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.001, 0], [0.001, -0.003]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{ speedKn: 15 },
|
||||
{
|
||||
minLookaheadM: 50,
|
||||
maxLookaheadM: 500,
|
||||
lookaheadTimeS: 30,
|
||||
turnSampleDistanceM: 30,
|
||||
turnSearchDistanceM: 1_000
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.nextTurn?.direction).toBe("starboard");
|
||||
expect(result.lookaheadDistanceM).toBeCloseTo(111.2, 0);
|
||||
expect(result.lookaheadPoint.lon).toBeCloseTo(0.001, 6);
|
||||
expect(result.lookaheadPoint.lat).toBeCloseTo(0, 6);
|
||||
expect(result.desiredCourseDeg).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it("returns a wrapped course correction and no invented correction without heading", () => {
|
||||
const route = [[0, 0], [0.001, 0.02]] as const;
|
||||
const withHeading = requireGuidance(route, { lat: 0, lon: 0 }, { headingDeg: 355 });
|
||||
const withoutHeading = requireGuidance(route);
|
||||
|
||||
expect(withHeading.desiredCourseDeg).toBeGreaterThan(2);
|
||||
expect(withHeading.desiredCourseDeg).toBeLessThan(4);
|
||||
expect(withHeading.courseCorrectionDeg).toBeGreaterThan(7);
|
||||
expect(withHeading.courseCorrectionDeg).toBeLessThan(9);
|
||||
expect(withoutHeading.courseCorrectionDeg).toBeNull();
|
||||
});
|
||||
|
||||
it("reports progress, route remainder, and direct destination distance", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.01, 0], [0.02, 0]],
|
||||
{ lat: 0, lon: 0.0075 }
|
||||
);
|
||||
|
||||
expect(result.routeLengthM).toBeCloseTo(2_224, 0);
|
||||
expect(result.progressM).toBeCloseTo(834, 0);
|
||||
expect(result.progressRatio).toBeCloseTo(0.375, 3);
|
||||
expect(result.remainingRouteDistanceM).toBeCloseTo(1_390, 0);
|
||||
expect(result.distanceToDestinationM).toBeCloseTo(1_390, 0);
|
||||
});
|
||||
|
||||
it("distinguishes confirmed off-route fixes from unreliable GPS fixes", () => {
|
||||
const route = [[0, 0], [0.02, 0]] as const;
|
||||
const offRoute = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(170), lon: 0.005 },
|
||||
{ accuracyM: 20 },
|
||||
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
|
||||
);
|
||||
const inaccurate = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(170), lon: 0.005 },
|
||||
{ accuracyM: 150 },
|
||||
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
|
||||
);
|
||||
|
||||
expect(offRoute.conservativeDistanceToRouteM).toBeCloseTo(150, 0);
|
||||
expect(offRoute.isOffRoute).toBe(true);
|
||||
expect(offRoute.status).toBe("off-route");
|
||||
expect(inaccurate.positionReliable).toBe(false);
|
||||
expect(inaccurate.isOffRoute).toBe(false);
|
||||
expect(inaccurate.status).toBe("gps-unreliable");
|
||||
});
|
||||
|
||||
it("marks arrival only near the route end", () => {
|
||||
const route = [[0, 0], [0.01, 0]] as const;
|
||||
const arrived = requireGuidance(route, { lat: 0, lon: 0.0099 }, { accuracyM: 5 });
|
||||
const nearDestinationButAtEarlyLoop = requireGuidance(
|
||||
[[0, 0], [0.02, 0], [0.0001, 0]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{ accuracyM: 2 }
|
||||
);
|
||||
|
||||
expect(arrived.status).toBe("arrived");
|
||||
expect(arrived.remainingRouteDistanceM).toBeLessThan(15);
|
||||
expect(nearDestinationButAtEarlyLoop.status).not.toBe("arrived");
|
||||
});
|
||||
|
||||
it("identifies starboard and port course changes with an advance warning", () => {
|
||||
const options = {
|
||||
turnSampleDistanceM: 40,
|
||||
turnNoticeDistanceM: 200,
|
||||
turnSearchDistanceM: 1_000,
|
||||
minLookaheadM: 20,
|
||||
maxLookaheadM: 20
|
||||
};
|
||||
const starboard = requireGuidance(
|
||||
[[0, 0], [0.005, 0], [0.005, -0.005]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{},
|
||||
options
|
||||
);
|
||||
const port = requireGuidance(
|
||||
[[0, 0], [0.005, 0], [0.005, 0.005]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{},
|
||||
options
|
||||
);
|
||||
|
||||
expect(starboard.nextTurn).toEqual(expect.objectContaining({ direction: "starboard" }));
|
||||
expect(starboard.nextTurn?.courseChangeDeg).toBeCloseTo(90, 0);
|
||||
expect(starboard.nextTurn?.distanceM).toBeCloseTo(111, 0);
|
||||
expect(starboard.status).toBe("approaching-turn");
|
||||
expect(port.nextTurn).toEqual(expect.objectContaining({ direction: "port" }));
|
||||
expect(port.nextTurn?.courseChangeDeg).toBeCloseTo(-90, 0);
|
||||
});
|
||||
|
||||
it("does not call insignificant line noise a turn", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.001, 0], [0.002, 0.00001], [0.003, 0]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{},
|
||||
{ turnMinimumChangeDeg: 20, turnSearchDistanceM: 1_000 }
|
||||
);
|
||||
|
||||
expect(result.nextTurn).toBeNull();
|
||||
expect(result.status).toBe("on-route");
|
||||
});
|
||||
|
||||
it("handles geodesic projection and lookahead across the antimeridian", () => {
|
||||
const result = requireGuidance(
|
||||
[[179.9, 0], [-179.9, 0]],
|
||||
{ lat: 0.001, lon: 180 },
|
||||
{},
|
||||
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
|
||||
);
|
||||
|
||||
expect(result.distanceToRouteM).toBeCloseTo(111.2, 0);
|
||||
expect(result.progressRatio).toBeCloseTo(0.5, 2);
|
||||
expect(Math.abs(result.nearestRoutePoint.lon)).toBeCloseTo(180, 5);
|
||||
expect(result.lookaheadPoint.lon).toBeLessThan(-179.9);
|
||||
});
|
||||
|
||||
it("uses previous progress to disambiguate crossings without a distant forward jump", () => {
|
||||
const crossingRoute = [
|
||||
[-0.01, -0.01],
|
||||
[0.01, 0.01],
|
||||
[0.01, -0.01],
|
||||
[-0.01, 0.01]
|
||||
] as const;
|
||||
// This point is exactly on the late diagonal and only a few metres from
|
||||
// the early diagonal at their crossing.
|
||||
const crossingPosition = { lat: -0.00002, lon: 0.00002 };
|
||||
const early = requireGuidance(
|
||||
crossingRoute,
|
||||
crossingPosition,
|
||||
{ previousProgressM: 1_500 },
|
||||
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
|
||||
);
|
||||
const late = requireGuidance(
|
||||
crossingRoute,
|
||||
crossingPosition,
|
||||
{ previousProgressM: 6_800 },
|
||||
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
|
||||
);
|
||||
|
||||
expect(early.progressM).toBeGreaterThan(1_500);
|
||||
expect(early.progressM).toBeLessThan(2_000);
|
||||
expect(late.progressM).toBeGreaterThan(6_500);
|
||||
expect(early.nearestSegmentIndex).toBe(0);
|
||||
expect(late.nearestSegmentIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("holds monotonic progress against small backwards GPS movement", () => {
|
||||
const previousProgressM = 0.005 * METRES_PER_EQUATOR_DEGREE;
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.02, 0]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{ previousProgressM }
|
||||
);
|
||||
|
||||
expect(result.progressM).toBeCloseTo(previousProgressM, 3);
|
||||
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.004, 6);
|
||||
});
|
||||
|
||||
it("accepts GeoJSON and RouteResult shapes and rejects invalid or degenerate input", () => {
|
||||
const geometry: GeoJsonLineString = {
|
||||
type: "LineString",
|
||||
coordinates: [[7, 53], [7.01, 53.01]]
|
||||
};
|
||||
const routeResult = {
|
||||
geometry,
|
||||
distanceNm: 1,
|
||||
eta: null,
|
||||
warnings: [],
|
||||
minKnownDepthM: null,
|
||||
unknownDepthRatio: 1,
|
||||
dataSources: []
|
||||
};
|
||||
|
||||
expect(calculateRouteGuidance({ route: geometry, position: { lat: 53, lon: 7 } })).not.toBeNull();
|
||||
expect(calculateRouteGuidance({ route: routeResult, position: { lat: 53, lon: 7 } })).not.toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53]],
|
||||
position: { lat: 53, lon: 7 }
|
||||
})).toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53], [7, 53]],
|
||||
position: { lat: 53, lon: 7 }
|
||||
})).toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53], [7.01, 53.01]],
|
||||
position: { lat: Number.NaN, lon: 7 }
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildVoyagePlan,
|
||||
harbourAmenitiesFromProperties,
|
||||
orderWaypointsAlongRoute,
|
||||
type RouteResult,
|
||||
type VoyageHarbour
|
||||
} from "../src/index.js";
|
||||
|
||||
function eastboundRoute(lengthDegrees = 1.8): Pick<RouteResult, "geometry" | "distanceNm"> {
|
||||
return {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[0, 0],
|
||||
[lengthDegrees / 2, 0],
|
||||
[lengthDegrees, 0]
|
||||
]
|
||||
},
|
||||
distanceNm: lengthDegrees * 60
|
||||
};
|
||||
}
|
||||
|
||||
function harbour(
|
||||
id: string,
|
||||
lon: number,
|
||||
amenities: VoyageHarbour["amenities"] = {}
|
||||
): VoyageHarbour {
|
||||
return {
|
||||
id,
|
||||
name: `Hafen ${id}`,
|
||||
kind: "marina",
|
||||
coordinate: { lat: 0.005, lon },
|
||||
amenities
|
||||
};
|
||||
}
|
||||
|
||||
describe("voyage planning", () => {
|
||||
it("orders named waypoints by their position along the route", () => {
|
||||
const waypoints = orderWaypointsAlongRoute(
|
||||
[
|
||||
{ id: "late", name: "Später", coordinate: { lat: 0.01, lon: 1.2 } },
|
||||
{ id: "early", name: "Früher", coordinate: { lat: -0.01, lon: 0.3 } }
|
||||
],
|
||||
eastboundRoute()
|
||||
);
|
||||
|
||||
expect(waypoints.map(({ id }) => id)).toEqual(["early", "late"]);
|
||||
expect(waypoints.map(({ sequence }) => sequence)).toEqual([1, 2]);
|
||||
expect(waypoints[0]!.coordinate).toEqual({ lat: -0.01, lon: 0.3 });
|
||||
expect(waypoints[0]!.routeDistanceNm).toBeCloseTo(18, 0);
|
||||
expect(waypoints[0]!.distanceFromRouteNm).toBeCloseTo(0.6, 0);
|
||||
});
|
||||
|
||||
it("creates daily stages at suitable nearby harbours and assigns crossed waypoints", () => {
|
||||
const plan = buildVoyagePlan({
|
||||
route: eastboundRoute(),
|
||||
cruiseSpeedKn: 10,
|
||||
maxCruisingHoursPerDay: 5,
|
||||
requiredAmenities: ["water", "overnight"],
|
||||
waypoints: [
|
||||
{ id: "second", name: "Zweiter Wegpunkt", coordinate: { lat: 0, lon: 1.2 } },
|
||||
{ id: "first", name: "Erster Wegpunkt", coordinate: { lat: 0, lon: 0.3 } }
|
||||
],
|
||||
harbours: [
|
||||
harbour("unsuitable", 0.8, { overnight: true, water: false }),
|
||||
harbour("day-one", 0.75, { overnight: true, water: true }),
|
||||
harbour("day-two", 1.5, { overnight: "available", water: "available" })
|
||||
]
|
||||
});
|
||||
|
||||
expect(plan.legs).toHaveLength(3);
|
||||
expect(plan.legs.map((leg) => leg.end.name)).toEqual([
|
||||
"Hafen day-one",
|
||||
"Hafen day-two",
|
||||
"Ziel"
|
||||
]);
|
||||
expect(plan.legs.every((leg) => leg.durationHours <= 5.01)).toBe(true);
|
||||
expect(plan.legs[0]!.waypoints.map(({ id }) => id)).toEqual(["first"]);
|
||||
expect(plan.legs[1]!.waypoints.map(({ id }) => id)).toEqual(["second"]);
|
||||
expect(plan.warnings.some(({ code }) => code === "NO_SUITABLE_HARBOUR")).toBe(false);
|
||||
expect(plan.totalDistanceNm).toBeGreaterThan(plan.totalRouteDistanceNm);
|
||||
});
|
||||
|
||||
it("uses an explicit route target and warns when no suitable harbour exists", () => {
|
||||
const plan = buildVoyagePlan({
|
||||
route: eastboundRoute(1.2),
|
||||
cruiseSpeedKn: 6,
|
||||
maxCruisingHoursPerDay: 5,
|
||||
requiredAmenities: ["fuel"],
|
||||
maxHarbourDetourNm: 1,
|
||||
harbours: [
|
||||
harbour("no-fuel", 0.4, { fuel: false }),
|
||||
{ ...harbour("too-far", 0.45, { fuel: true }), coordinate: { lat: 0.1, lon: 0.45 } }
|
||||
]
|
||||
});
|
||||
|
||||
expect(plan.legs).toHaveLength(3);
|
||||
expect(plan.legs[0]!.end.type).toBe("route");
|
||||
expect(plan.legs[1]!.end.type).toBe("route");
|
||||
expect(plan.legs[2]!.end.type).toBe("destination");
|
||||
expect(plan.warnings.filter(({ code }) => code === "NO_SUITABLE_HARBOUR")).toHaveLength(2);
|
||||
expect(plan.warnings[0]!.message).toContain("Treibstoff");
|
||||
expect(plan.warnings[0]!.message).toContain("kein bestätigter Liegeplatz");
|
||||
});
|
||||
|
||||
it("prefers a safe short harbour stage and reports why it ends early", () => {
|
||||
const plan = buildVoyagePlan({
|
||||
route: eastboundRoute(1.2),
|
||||
cruiseSpeedKn: 10,
|
||||
maxCruisingHoursPerDay: 5,
|
||||
harbours: [harbour("early", 0.2)]
|
||||
});
|
||||
|
||||
expect(plan.legs[0]!.end.name).toBe("Hafen early");
|
||||
expect(plan.warnings.some(({ code }) => code === "SHORT_STAGE_FOR_HARBOUR")).toBe(true);
|
||||
});
|
||||
|
||||
it("warns about waypoints that are implausibly far from the routed line", () => {
|
||||
const plan = buildVoyagePlan({
|
||||
route: eastboundRoute(0.2),
|
||||
cruiseSpeedKn: 6,
|
||||
maxCruisingHoursPerDay: 5,
|
||||
maxWaypointDistanceFromRouteNm: 1,
|
||||
waypoints: [
|
||||
{ id: "off-route", name: "Falscher Abzweig", coordinate: { lat: 0.1, lon: 0.1 } }
|
||||
]
|
||||
});
|
||||
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "WAYPOINT_OFF_ROUTE", severity: "caution" })
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes common OSM harbour amenity tags without treating missing data as available", () => {
|
||||
expect(
|
||||
harbourAmenitiesFromProperties({
|
||||
power_supply: "yes",
|
||||
drinking_water: true,
|
||||
"fuel:diesel": "no",
|
||||
amenity: "waste_disposal",
|
||||
guest_berths: "12"
|
||||
})
|
||||
).toEqual({
|
||||
electricity: "available",
|
||||
water: "available",
|
||||
fuel: "unavailable",
|
||||
waste: "available",
|
||||
overnight: "available"
|
||||
});
|
||||
|
||||
expect(harbourAmenitiesFromProperties({}).overnight).toBe("unknown");
|
||||
expect(harbourAmenitiesFromProperties({ fuel: "no", "fuel:diesel": "yes" }).fuel).toBe(
|
||||
"available"
|
||||
);
|
||||
expect(harbourAmenitiesFromProperties({ drinking_water: null }).water).toBe("unknown");
|
||||
});
|
||||
|
||||
it("rejects unsafe daily planning inputs", () => {
|
||||
expect(() =>
|
||||
buildVoyagePlan({
|
||||
route: eastboundRoute(),
|
||||
cruiseSpeedKn: 0,
|
||||
maxCruisingHoursPerDay: 5
|
||||
})
|
||||
).toThrow(/cruiseSpeedKn/);
|
||||
expect(() =>
|
||||
buildVoyagePlan({
|
||||
route: eastboundRoute(),
|
||||
cruiseSpeedKn: 6,
|
||||
maxCruisingHoursPerDay: Number.NaN
|
||||
})
|
||||
).toThrow(/maxCruisingHoursPerDay/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user