Files
2026-07-24 11:29:24 +02:00

178 lines
5.7 KiB
TypeScript

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/);
});
});