290 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|