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