import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TideSummary } from "@watermaps/shared"; import type { GpsState } from "../src/hooks/useGeolocation"; const apiMocks = vi.hoisted(() => ({ getNearestTide: vi.fn() })); vi.mock("../src/api", () => apiMocks); import { useAnchorWatch } from "../src/hooks/useAnchorWatch"; const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate"); beforeEach(() => { apiMocks.getNearestTide.mockReset(); apiMocks.getNearestTide.mockResolvedValue(tideFixture()); }); afterEach(() => { cleanup(); vi.useRealTimers(); if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate); else Reflect.deleteProperty(navigator, "vibrate"); }); describe("useAnchorWatch", () => { it("captures an immutable anchor point only after an explicit action", async () => { const gps = gpsFix({ lat: 53.2, lon: 7.1 }); const { result, rerender } = renderHook( ({ value }) => useAnchorWatch(value), { initialProps: { value: gps } } ); expect(result.current.phase).toBe("idle"); expect(result.current.anchorPoint).toBeNull(); act(() => expect(result.current.captureAnchor()).toBe(true)); expect(result.current.phase).toBe("set"); expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 }); rerender({ value: gpsFix({ lat: 53.21, lon: 7.11 }) }); expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 }); await waitFor(() => expect(result.current.tide?.station).toBe("Testpegel")); expect(apiMocks.getNearestTide).toHaveBeenCalledWith( { lat: 53.2, lon: 7.1 }, expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/) ); }); it("rejects stale and inaccurate fixes instead of silently setting a point", () => { const stale = gpsFix({ lat: 53.2, lon: 7.1 }, { timestampMs: Date.now() - 20_000 }); const { result, rerender } = renderHook( ({ value }) => useAnchorWatch(value), { initialProps: { value: stale } } ); act(() => expect(result.current.captureAnchor()).toBe(false)); expect(result.current.operationError).toMatch(/älter als 10 Sekunden/); expect(result.current.anchorPoint).toBeNull(); rerender({ value: gpsFix({ lat: 53.2, lon: 7.1 }, { accuracyM: 55 }) }); act(() => expect(result.current.captureAnchor()).toBe(false)); expect(result.current.operationError).toMatch(/GPS noch zu ungenau/); expect(result.current.anchorPoint).toBeNull(); }); it("warns and vibrates once a conservative GPS distance exceeds the radius", async () => { const vibrate = vi.fn(); Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate }); const initial = gpsFix({ lat: 0, lon: 0 }); const { result, rerender } = renderHook( ({ value }) => useAnchorWatch(value), { initialProps: { value: initial } } ); act(() => { result.current.captureAnchor(); result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 }); }); await act(async () => { expect(await result.current.arm()).toBe(true); }); expect(result.current.positionAlarm).toBe(false); rerender({ value: gpsFix( { lat: 150 / 111_195.08, lon: 0 }, { accuracyM: 10, timestampMs: Date.now() } ) }); await waitFor(() => expect(result.current.positionAlarm).toBe(true)); expect(result.current.watchResult?.alarmTriggered).toBe(true); expect(result.current.watchResult?.conservativeDistanceFromAnchorM).toBeGreaterThan(100); expect(vibrate).toHaveBeenCalledTimes(1); rerender({ value: gpsFix( { lat: 155 / 111_195.08, lon: 0 }, { accuracyM: 10, timestampMs: Date.now() } ) }); expect(vibrate).toHaveBeenCalledTimes(1); }); it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => { apiMocks.getNearestTide.mockResolvedValue(tideFixture(6)); const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 }))); act(() => result.current.captureAnchor()); await waitFor(() => expect(result.current.tideWindow?.coverage).toBe("partial")); expect(result.current.rodePlan?.calculationComplete).toBe(false); expect(result.current.rodePlan?.rodeReserveM).toBeNull(); await act(async () => { expect(await result.current.arm()).toBe(true); }); expect(result.current.phase).toBe("armed"); }); }); function gpsFix( position: { lat: number; lon: number }, overrides: Partial> = {} ): Pick { return { status: overrides.status ?? "tracking", position, accuracyM: overrides.accuracyM ?? 5, timestampMs: overrides.timestampMs ?? Date.now() }; } function tideFixture(hours = 30): TideSummary { const now = Date.now(); return { station: "Testpegel", distanceKm: 3.2, nextHigh: null, nextLow: null, waterLevelCurve: [ { time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 }, { time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 }, { time: new Date(now + hours * 60 * 60_000).toISOString(), predictedM: 0.5 } ], source: "Test", updatedAt: new Date(now).toISOString() }; }