289 lines
9.9 KiB
TypeScript
289 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|