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[0]> = {}, options: Parameters[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: [], minKnownDepthM: null, unknownDepthRatio: 1, 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(); }); });