Files
watermaps/apps/web/tests/course-assistant-hook.test.tsx
T
2026-07-24 11:29:24 +02:00

109 lines
3.4 KiB
TypeScript

import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { useCourseAssistant, type CourseAssistantInput } from "../src/hooks/useCourseAssistant";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useCourseAssistant", () => {
it("never starts automatically and calculates only after an explicit start", () => {
const { result } = renderHook(() => useCourseAssistant(input()));
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
act(() => result.current.start());
expect(result.current.active).toBe(true);
expect(result.current.guidance?.status).toBe("on-route");
expect(result.current.guidance?.desiredCourseDeg).toBeCloseTo(90, 0);
});
it("stops deliberately when a different route is selected", () => {
const first = route("first", [[0, 0], [0.02, 0]]);
const second = route("second", [[0, 0], [0, 0.02]]);
const { result, rerender } = renderHook(
({ currentRoute }) => useCourseAssistant(input(currentRoute)),
{ initialProps: { currentRoute: first } }
);
act(() => result.current.start());
expect(result.current.active).toBe(true);
rerender({ currentRoute: second });
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
});
it("pauses guidance when no fresh GPS fix arrives", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-20T10:00:00.000Z"));
const fixTimestampMs = Date.now();
const { result } = renderHook(() => useCourseAssistant(input(routeFixture, fixTimestampMs)));
act(() => result.current.start());
expect(result.current.guidance).not.toBeNull();
act(() => vi.advanceTimersByTime(20_000));
expect(result.current.fixStale).toBe(true);
expect(result.current.guidance).toBeNull();
});
it("vibrates once when crossing into the off-route state", () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const { result, rerender } = renderHook(
({ lat }) => useCourseAssistant(input(routeFixture, Date.now(), lat)),
{ initialProps: { lat: 0 } }
);
act(() => result.current.start());
expect(vibrate).not.toHaveBeenCalled();
rerender({ lat: 0.003 });
expect(result.current.guidance?.status).toBe("off-route");
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({ lat: 0.0031 });
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
function input(
currentRoute: RouteResult = routeFixture,
fixTimestampMs = Date.now(),
lat = 0
): CourseAssistantInput {
return {
route: currentRoute,
position: { lon: 0.001, lat },
accuracyM: 5,
speedKn: 6,
headingDeg: 90,
fixTimestampMs
};
}
function route(id: string, coordinates: [number, number][]): RouteResult {
return {
id,
name: id,
geometry: { type: "LineString", coordinates },
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
}
const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);