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

149 lines
5.3 KiB
TypeScript

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<GpsState, "accuracyM" | "timestampMs" | "status">> = {}
): Pick<GpsState, "status" | "position" | "accuracyM" | "timestampMs"> {
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()
};
}