import { describe, expect, it } from "vitest"; import { buildVoyagePlan, harbourAmenitiesFromProperties, orderWaypointsAlongRoute, type RouteResult, type VoyageHarbour } from "../src/index.js"; function eastboundRoute(lengthDegrees = 1.8): Pick { 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/); }); });