178 lines
5.7 KiB
TypeScript
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/);
|
|
});
|
|
});
|