import "@testing-library/jest-dom/vitest"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RouteResult } from "@watermaps/shared"; import { VoyageNavigationTools } from "../src/components/VoyageNavigationTools"; const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation"); const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate"); beforeEach(() => localStorage.clear()); afterEach(() => { cleanup(); localStorage.clear(); restoreNavigatorProperty("geolocation", originalGeolocation); restoreNavigatorProperty("vibrate", originalVibrate); }); describe("VoyageNavigationTools", () => { it("does not request geolocation before the skipper starts the alarm", () => { const watchPosition = vi.fn(() => 17); installGeolocation(watchPosition, vi.fn()); render(); expect(watchPosition).not.toHaveBeenCalled(); expect(screen.getByText(/GPS wird erst nach/)).toBeInTheDocument(); }); it("does not open a second GPS watch while the course assistant is active", () => { const watchPosition = vi.fn(() => 17); installGeolocation(watchPosition, vi.fn()); render(); expect(screen.getByRole("button", { name: "Kursalarm ist im Kursassistenten enthalten" })).toBeDisabled(); expect(screen.getByText(/Querabweichung.*Kursassistenten/)).toBeVisible(); expect(watchPosition).not.toHaveBeenCalled(); }); it("starts explicitly and warns when the vessel leaves the route", () => { let success: PositionCallback | undefined; const watchPosition = vi.fn((next: PositionCallback) => { success = next; return 42; }); const clearWatch = vi.fn(); const vibrate = vi.fn(); installGeolocation(watchPosition, clearWatch); Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate }); render(); fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm starten" })); expect(watchPosition).toHaveBeenCalledTimes(1); expect(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })).toBeInTheDocument(); act(() => success?.(positionAt(53.01, 7.005, 5))); expect(screen.getByRole("alert")).toHaveTextContent(/von der Route entfernt/); expect(vibrate).toHaveBeenCalledWith([200, 100, 200]); fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })); expect(clearWatch).toHaveBeenCalledWith(42); }); it("saves and restores the route and plan on this device", () => { const onLoad = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: "Route offline speichern" })); expect(screen.getByText(/ist auf diesem Gerät offline verfügbar/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Offline-Route laden" })); expect(onLoad).toHaveBeenCalledTimes(1); expect(onLoad.mock.calls[0]?.[0].route.geometry).toEqual(routeFixture.geometry); expect(onLoad.mock.calls[0]?.[0].plan.vesselProfile.cruiseSpeedKn).toBe(6); }); }); function installGeolocation(watchPosition: typeof navigator.geolocation.watchPosition, clearWatch: typeof navigator.geolocation.clearWatch) { Object.defineProperty(navigator, "geolocation", { configurable: true, value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() } }); } function restoreNavigatorProperty(name: "geolocation" | "vibrate", descriptor: PropertyDescriptor | undefined) { if (descriptor) { Object.defineProperty(navigator, name, descriptor); } else { Reflect.deleteProperty(navigator, name); } } function positionAt(lat: number, lon: number, accuracy: number): GeolocationPosition { return { coords: { latitude: lat, longitude: lon, accuracy, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) }, timestamp: Date.now(), toJSON: () => ({}) }; } const routeFixture: RouteResult = { id: "test-route", name: "Testfahrt", geometry: { type: "LineString", coordinates: [[7, 53], [7.01, 53]] }, distanceNm: 0.4, eta: null, warnings: [], dataSources: ["Test"], routingMode: "fairway" };