271 lines
9.1 KiB
TypeScript
271 lines
9.1 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");
|
|
const originalAudioContext = Object.getOwnPropertyDescriptor(window, "AudioContext");
|
|
const originalWebkitAudioContext = Object.getOwnPropertyDescriptor(window, "webkitAudioContext");
|
|
|
|
beforeEach(() => {
|
|
apiMocks.getNearestTide.mockReset();
|
|
apiMocks.getNearestTide.mockResolvedValue(tideFixture());
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
|
|
else Reflect.deleteProperty(navigator, "vibrate");
|
|
restoreProperty(window, "AudioContext", originalAudioContext);
|
|
restoreProperty(window, "webkitAudioContext", originalWebkitAudioContext);
|
|
});
|
|
|
|
describe("useAnchorWatch", () => {
|
|
it("uses the current boat's bow roller height instead of a separate default", () => {
|
|
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
|
|
const { result, rerender } = renderHook(
|
|
({ bowRollerHeightM }) =>
|
|
useAnchorWatch(gps, { bowRollerHeightM }),
|
|
{ initialProps: { bowRollerHeightM: 1.4 } }
|
|
);
|
|
|
|
expect(result.current.settings.bowRollerHeightM).toBe(1.4);
|
|
|
|
rerender({ bowRollerHeightM: 1.8 });
|
|
expect(result.current.settings.bowRollerHeightM).toBe(1.8);
|
|
});
|
|
|
|
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("repeats the audible drift alarm until it is explicitly acknowledged", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-07-26T02:00:00.000Z"));
|
|
const vibrate = vi.fn();
|
|
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
|
|
const audio = installAudioContextMock();
|
|
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);
|
|
});
|
|
|
|
rerender({
|
|
value: gpsFix(
|
|
{ lat: 150 / 111_195.08, lon: 0 },
|
|
{ accuracyM: 10, timestampMs: Date.now() }
|
|
)
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current.driftAlarm).toBe(true);
|
|
expect(result.current.audibleDriftAlarmActive).toBe(true);
|
|
expect(audio.start).toHaveBeenCalledTimes(3);
|
|
expect(vibrate).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1_300);
|
|
await Promise.resolve();
|
|
});
|
|
expect(audio.start).toHaveBeenCalledTimes(6);
|
|
expect(vibrate).toHaveBeenCalledTimes(2);
|
|
|
|
act(() => result.current.acknowledgeAlarm());
|
|
expect(result.current.alarmAcknowledged).toBe(true);
|
|
expect(result.current.audibleDriftAlarmActive).toBe(false);
|
|
const startsAfterAcknowledgement = audio.start.mock.calls.length;
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(5_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(audio.start).toHaveBeenCalledTimes(startsAfterAcknowledgement);
|
|
expect(vibrate).toHaveBeenLastCalledWith(0);
|
|
});
|
|
|
|
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()
|
|
};
|
|
}
|
|
|
|
function installAudioContextMock() {
|
|
const start = vi.fn();
|
|
const stop = vi.fn();
|
|
const context = {
|
|
state: "running",
|
|
currentTime: 0,
|
|
destination: {},
|
|
resume: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
createOscillator: vi.fn(() => ({
|
|
type: "sine",
|
|
frequency: { value: 0 },
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
start,
|
|
stop,
|
|
onended: null
|
|
})),
|
|
createGain: vi.fn(() => ({
|
|
gain: {
|
|
setValueAtTime: vi.fn(),
|
|
exponentialRampToValueAtTime: vi.fn()
|
|
},
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn()
|
|
}))
|
|
};
|
|
function AudioContextMock() {
|
|
return context;
|
|
}
|
|
Object.defineProperty(window, "AudioContext", {
|
|
configurable: true,
|
|
value: AudioContextMock
|
|
});
|
|
return { context, start, stop };
|
|
}
|
|
|
|
function restoreProperty(
|
|
target: object,
|
|
property: PropertyKey,
|
|
descriptor: PropertyDescriptor | undefined
|
|
) {
|
|
if (descriptor) {
|
|
Object.defineProperty(target, property, descriptor);
|
|
} else {
|
|
Reflect.deleteProperty(target, property);
|
|
}
|
|
}
|