import { describe, expect, it } from "vitest"; import type { RouteResult, VoyageHarbour } from "@watermaps/shared"; import { DEFAULT_ROUTE_EVENT_CORRIDORS_NM, nextRouteEventsByKind, upcomingRouteEvents } from "../src/routeEvents"; import type { RouteBridgeAssessment } from "../src/routeWeatherReport"; import type { RouteLock } from "../src/voyageHarbours"; const route: RouteResult = { id: "eastbound", geometry: { type: "LineString", coordinates: [ [0, 0], [1, 0] ] }, distanceNm: 60, eta: null, warnings: [], dataSources: ["test"] }; describe("upcomingRouteEvents", () => { it("projects mixed facilities, filters passed and off-corridor entries, and orders them along the route", () => { const events = upcomingRouteEvents({ route, progressNm: 12, harbours: [ harbour("passed-harbour", 0.1, 0), harbour("next-harbour", 0.6, 0), harbour("off-route-harbour", 0.5, 0.05) ], locks: [ lock("next-lock", 0.4, 0, 999, 0), lock("off-route-lock", 0.5, 0.01, 1, 0) ], bridges: [ bridge("next-bridge", 0.3, 0.0005, 99), bridge("off-route-bridge", 0.35, 0.01, 0.001) ] }); expect(events.map((event) => `${event.kind}:${event.id}`)).toEqual([ "bridge:next-bridge", "lock:next-lock", "harbour:next-harbour" ]); expect(events.every((event) => event.routeDistanceNm >= 12)).toBe(true); expect(events.every((event) => event.remainingNm >= 0)).toBe(true); }); it("reprojects locks and bridges instead of trusting their existing distance fields", () => { const [projectedLock, projectedBridge] = upcomingRouteEvents({ route, locks: [lock("lock", 0.25, 0, 999, 777)], bridges: [bridge("bridge", 0.75, 0, 999)] }); expect(projectedLock?.kind).toBe("lock"); expect(projectedLock?.routeDistanceNm).toBeCloseTo(15, 0); expect(projectedLock?.distanceFromRouteNm).toBeCloseTo(0, 6); expect(projectedBridge?.kind).toBe("bridge"); expect(projectedBridge?.routeDistanceNm).toBeCloseTo(45, 0); expect(projectedBridge?.distanceFromRouteNm).toBeCloseTo(0, 6); }); it("uses a corridor override without changing the defaults for other kinds", () => { const events = upcomingRouteEvents({ route, corridorsNm: { harbour: 4 }, harbours: [harbour("detour", 0.5, 0.05)], locks: [lock("off-route-lock", 0.5, 0.01, 0, 0)] }); expect(events.map((event) => event.id)).toEqual(["detour"]); }); it("uses a focused half-mile harbour corridor by default", () => { expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5); const events = upcomingRouteEvents({ route, harbours: [ harbour("near-harbour", 0.4, 0.008), harbour("unrelated-harbour", 0.5, 0.01) ] }); expect(events.map((event) => event.id)).toEqual(["near-harbour"]); }); it("prefers a nearby same-named lock over a duplicate harbour classification", () => { const duplicateHarbour = { ...harbour("harbour-lock", 0.4, 0.0005), name: "Nesserländer Schleuse" }; const duplicateLock = { ...lock("lock", 0.4, 0, 0, 0), name: "Nesserlander Schleuse" }; const distinctHarbour = { ...harbour("real-harbour", 0.6, 0), name: "Stadthafen" }; const events = upcomingRouteEvents({ route, harbours: [duplicateHarbour, distinctHarbour], locks: [duplicateLock] }); expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([ "lock:Nesserlander Schleuse", "harbour:Stadthafen" ]); }); it("does not merge same-named facilities that are spatially distinct", () => { const events = upcomingRouteEvents({ route, harbours: [ { ...harbour("harbour", 0.4, 0.003), name: "Kanalschleuse" } ], locks: [ { ...lock("lock", 0.4, 0, 0, 0), name: "Kanalschleuse" } ] }); expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]); }); it("calculates ETA and retains both speed and reference-time provenance", () => { const [event] = upcomingRouteEvents({ route, progressNm: 6, locks: [lock("lock", 0.5, 0, 0, 0)], etaBasis: { speedKn: 6, speedSource: "vessel-cruise-speed", referenceTime: "2026-07-23T08:00:00.000Z", referenceSource: "route-departure" } }); expect(event).toBeDefined(); expect(event!.remainingNm).toBeCloseTo(24, 0); expect(event!.eta).toMatchObject({ speedKn: 6, speedSource: "vessel-cruise-speed", referenceTime: "2026-07-23T08:00:00.000Z", referenceSource: "route-departure" }); expect(event!.eta!.minutesFromProgress).toBeCloseTo( (event!.remainingNm / 6) * 60, 8 ); expect(Date.parse(event!.eta!.estimatedAt)).toBeCloseTo( Date.parse("2026-07-23T08:00:00.000Z") + event!.eta!.minutesFromProgress * 60_000, 0 ); }); it("omits ETA for an invalid assumption rather than inventing a speed", () => { const [event] = upcomingRouteEvents({ route, harbours: [harbour("harbour", 0.5, 0)], etaBasis: { speedKn: 0, speedSource: "gps-sog", referenceTime: "not-a-time", referenceSource: "current-time" } }); expect(event?.eta).toBeNull(); }); it("selects the first upcoming event of each kind from an ordered list", () => { const events = upcomingRouteEvents({ route, harbours: [ harbour("harbour-2", 0.8, 0), harbour("harbour-1", 0.2, 0) ], locks: [lock("lock-1", 0.3, 0, 0, 0)] }); const next = nextRouteEventsByKind(events); expect(next.harbour?.id).toBe("harbour-1"); expect(next.lock?.id).toBe("lock-1"); expect(next.bridge).toBeNull(); }); }); function harbour( id: string, lon: number, lat: number ): VoyageHarbour { return { id, name: id, kind: "harbour", coordinate: { lon, lat } }; } function lock( id: string, lon: number, lat: number, routeDistanceNm: number, distanceFromRouteNm: number ): RouteLock { return { id, name: id, coordinate: { lon, lat }, routeDistanceNm, distanceFromRouteNm, openingHours: null, phone: null, vhf: null, website: null }; } function bridge( id: string, lon: number, lat: number, distanceNm: number ): RouteBridgeAssessment { return { id, name: id, label: id, coordinate: { lon, lat }, distanceNm, clearanceM: null, clearanceLabel: null, requiredAirDraftM: null, marginM: null, status: "unknown", source: "test" }; }