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: [], dataSources: ["Test"], routingMode: "fairway" }; } const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);