Files
watermaps/packages/shared/tests/route-guidance.test.ts
T
BuTzZ b98ec8bc6f
Test and publish container images / test (push) Successful in 2m27s
Test and publish container images / publish (push) Failing after 4s
Hinweise optimiert
2026-07-27 13:04:32 +02:00

290 lines
10 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
calculateRouteGuidance,
type GeoJsonLineString,
type RouteGuidanceRoute
} from "../src/index.js";
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
function latitudeOffsetM(metres: number): number {
return metres / METRES_PER_EQUATOR_DEGREE;
}
function requireGuidance(
route: RouteGuidanceRoute,
position = { lat: 0, lon: 0 },
input: Partial<Parameters<typeof calculateRouteGuidance>[0]> = {},
options: Parameters<typeof calculateRouteGuidance>[1] = {}
) {
const result = calculateRouteGuidance({ route, position, ...input }, options);
expect(result).not.toBeNull();
if (!result) {
throw new Error("Expected valid route guidance");
}
return result;
}
describe("route guidance", () => {
it("projects a GPS fix geodesically and steers back towards the route", () => {
const result = requireGuidance(
[[0, 0], [0.02, 0]],
{ lat: latitudeOffsetM(100), lon: 0.005 },
{ headingDeg: 90, accuracyM: 5 },
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
);
expect(result.distanceToRouteM).toBeCloseTo(100, 0);
expect(result.crossTrackErrorM).toBeCloseTo(-100, 0);
expect(result.crossTrackSide).toBe("port");
expect(result.nearestRoutePoint.lat).toBeCloseTo(0, 6);
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.005, 5);
expect(result.desiredCourseDeg).toBeGreaterThan(90);
expect(result.desiredCourseDeg).toBeLessThan(180);
expect(result.courseCorrectionDeg).toBeGreaterThan(0);
expect(result.status).toBe("on-route");
});
it("adapts the interception course continuously to either side of the line", () => {
const route = [[0, 0], [0.02, 0]] as const;
const north = requireGuidance(
route,
{ lat: latitudeOffsetM(50), lon: 0.005 },
{},
{ minLookaheadM: 150, maxLookaheadM: 150 }
);
const south = requireGuidance(
route,
{ lat: latitudeOffsetM(-50), lon: 0.005 },
{},
{ minLookaheadM: 150, maxLookaheadM: 150 }
);
expect(north.desiredCourseDeg).toBeGreaterThan(90);
expect(south.desiredCourseDeg).toBeLessThan(90);
expect(north.crossTrackSide).toBe("port");
expect(south.crossTrackSide).toBe("starboard");
});
it("uses speed and GPS accuracy for lookahead and respects its upper bound", () => {
const route = [[0, 0], [0.2, 0]] as const;
const stationary = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 0, accuracyM: 0 });
const moving = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 10, accuracyM: 20 });
const capped = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 100, accuracyM: 20 });
expect(stationary.lookaheadDistanceM).toBeCloseTo(50, 6);
expect(moving.lookaheadDistanceM).toBeGreaterThan(stationary.lookaheadDistanceM);
expect(moving.lookaheadDistanceM).toBeCloseTo(182.89, 1);
expect(capped.lookaheadDistanceM).toBe(400);
});
it("caps its target at a significant bend instead of cutting across a canal corner", () => {
const result = requireGuidance(
[[0, 0], [0.001, 0], [0.001, -0.003]],
{ lat: 0, lon: 0 },
{ speedKn: 15 },
{
minLookaheadM: 50,
maxLookaheadM: 500,
lookaheadTimeS: 30,
turnSampleDistanceM: 30,
turnSearchDistanceM: 1_000
}
);
expect(result.nextTurn?.direction).toBe("starboard");
expect(result.lookaheadDistanceM).toBeCloseTo(111.2, 0);
expect(result.lookaheadPoint.lon).toBeCloseTo(0.001, 6);
expect(result.lookaheadPoint.lat).toBeCloseTo(0, 6);
expect(result.desiredCourseDeg).toBeCloseTo(90, 1);
});
it("returns a wrapped course correction and no invented correction without heading", () => {
const route = [[0, 0], [0.001, 0.02]] as const;
const withHeading = requireGuidance(route, { lat: 0, lon: 0 }, { headingDeg: 355 });
const withoutHeading = requireGuidance(route);
expect(withHeading.desiredCourseDeg).toBeGreaterThan(2);
expect(withHeading.desiredCourseDeg).toBeLessThan(4);
expect(withHeading.courseCorrectionDeg).toBeGreaterThan(7);
expect(withHeading.courseCorrectionDeg).toBeLessThan(9);
expect(withoutHeading.courseCorrectionDeg).toBeNull();
});
it("reports progress, route remainder, and direct destination distance", () => {
const result = requireGuidance(
[[0, 0], [0.01, 0], [0.02, 0]],
{ lat: 0, lon: 0.0075 }
);
expect(result.routeLengthM).toBeCloseTo(2_224, 0);
expect(result.progressM).toBeCloseTo(834, 0);
expect(result.progressRatio).toBeCloseTo(0.375, 3);
expect(result.remainingRouteDistanceM).toBeCloseTo(1_390, 0);
expect(result.distanceToDestinationM).toBeCloseTo(1_390, 0);
});
it("distinguishes confirmed off-route fixes from unreliable GPS fixes", () => {
const route = [[0, 0], [0.02, 0]] as const;
const offRoute = requireGuidance(
route,
{ lat: latitudeOffsetM(170), lon: 0.005 },
{ accuracyM: 20 },
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
);
const inaccurate = requireGuidance(
route,
{ lat: latitudeOffsetM(170), lon: 0.005 },
{ accuracyM: 150 },
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
);
expect(offRoute.conservativeDistanceToRouteM).toBeCloseTo(150, 0);
expect(offRoute.isOffRoute).toBe(true);
expect(offRoute.status).toBe("off-route");
expect(inaccurate.positionReliable).toBe(false);
expect(inaccurate.isOffRoute).toBe(false);
expect(inaccurate.status).toBe("gps-unreliable");
});
it("marks arrival only near the route end", () => {
const route = [[0, 0], [0.01, 0]] as const;
const arrived = requireGuidance(route, { lat: 0, lon: 0.0099 }, { accuracyM: 5 });
const nearDestinationButAtEarlyLoop = requireGuidance(
[[0, 0], [0.02, 0], [0.0001, 0]],
{ lat: 0, lon: 0 },
{ accuracyM: 2 }
);
expect(arrived.status).toBe("arrived");
expect(arrived.remainingRouteDistanceM).toBeLessThan(15);
expect(nearDestinationButAtEarlyLoop.status).not.toBe("arrived");
});
it("identifies starboard and port course changes with an advance warning", () => {
const options = {
turnSampleDistanceM: 40,
turnNoticeDistanceM: 200,
turnSearchDistanceM: 1_000,
minLookaheadM: 20,
maxLookaheadM: 20
};
const starboard = requireGuidance(
[[0, 0], [0.005, 0], [0.005, -0.005]],
{ lat: 0, lon: 0.004 },
{},
options
);
const port = requireGuidance(
[[0, 0], [0.005, 0], [0.005, 0.005]],
{ lat: 0, lon: 0.004 },
{},
options
);
expect(starboard.nextTurn).toEqual(expect.objectContaining({ direction: "starboard" }));
expect(starboard.nextTurn?.courseChangeDeg).toBeCloseTo(90, 0);
expect(starboard.nextTurn?.distanceM).toBeCloseTo(111, 0);
expect(starboard.status).toBe("approaching-turn");
expect(port.nextTurn).toEqual(expect.objectContaining({ direction: "port" }));
expect(port.nextTurn?.courseChangeDeg).toBeCloseTo(-90, 0);
});
it("does not call insignificant line noise a turn", () => {
const result = requireGuidance(
[[0, 0], [0.001, 0], [0.002, 0.00001], [0.003, 0]],
{ lat: 0, lon: 0 },
{},
{ turnMinimumChangeDeg: 20, turnSearchDistanceM: 1_000 }
);
expect(result.nextTurn).toBeNull();
expect(result.status).toBe("on-route");
});
it("handles geodesic projection and lookahead across the antimeridian", () => {
const result = requireGuidance(
[[179.9, 0], [-179.9, 0]],
{ lat: 0.001, lon: 180 },
{},
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
);
expect(result.distanceToRouteM).toBeCloseTo(111.2, 0);
expect(result.progressRatio).toBeCloseTo(0.5, 2);
expect(Math.abs(result.nearestRoutePoint.lon)).toBeCloseTo(180, 5);
expect(result.lookaheadPoint.lon).toBeLessThan(-179.9);
});
it("uses previous progress to disambiguate crossings without a distant forward jump", () => {
const crossingRoute = [
[-0.01, -0.01],
[0.01, 0.01],
[0.01, -0.01],
[-0.01, 0.01]
] as const;
// This point is exactly on the late diagonal and only a few metres from
// the early diagonal at their crossing.
const crossingPosition = { lat: -0.00002, lon: 0.00002 };
const early = requireGuidance(
crossingRoute,
crossingPosition,
{ previousProgressM: 1_500 },
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
);
const late = requireGuidance(
crossingRoute,
crossingPosition,
{ previousProgressM: 6_800 },
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
);
expect(early.progressM).toBeGreaterThan(1_500);
expect(early.progressM).toBeLessThan(2_000);
expect(late.progressM).toBeGreaterThan(6_500);
expect(early.nearestSegmentIndex).toBe(0);
expect(late.nearestSegmentIndex).toBe(2);
});
it("holds monotonic progress against small backwards GPS movement", () => {
const previousProgressM = 0.005 * METRES_PER_EQUATOR_DEGREE;
const result = requireGuidance(
[[0, 0], [0.02, 0]],
{ lat: 0, lon: 0.004 },
{ previousProgressM }
);
expect(result.progressM).toBeCloseTo(previousProgressM, 3);
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.004, 6);
});
it("accepts GeoJSON and RouteResult shapes and rejects invalid or degenerate input", () => {
const geometry: GeoJsonLineString = {
type: "LineString",
coordinates: [[7, 53], [7.01, 53.01]]
};
const routeResult = {
geometry,
distanceNm: 1,
eta: null,
warnings: [],
dataSources: []
};
expect(calculateRouteGuidance({ route: geometry, position: { lat: 53, lon: 7 } })).not.toBeNull();
expect(calculateRouteGuidance({ route: routeResult, position: { lat: 53, lon: 7 } })).not.toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53]],
position: { lat: 53, lon: 7 }
})).toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53], [7, 53]],
position: { lat: 53, lon: 7 }
})).toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53], [7.01, 53.01]],
position: { lat: Number.NaN, lon: 7 }
})).toBeNull();
});
});