import { cleanup, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RouteResult } from "@watermaps/shared"; import { useRouteEventFeatures } from "../src/hooks/useRouteEventFeatures"; const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() })); vi.mock("../src/api", () => ({ getMapFeatures: apiMocks.getMapFeatures })); beforeEach(() => { apiMocks.getMapFeatures.mockReset(); }); afterEach(cleanup); describe("useRouteEventFeatures", () => { it("loads harbours, locks and bridges once and independently", async () => { apiMocks.getMapFeatures.mockImplementation( async ({ layers }: { layers: string[] }) => featureCollection(layers[0]!) ); const activeRoute = route("first", 0); const { result } = renderHook(() => useRouteEventFeatures(activeRoute, { airDraftM: 3 }) ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3); expect( apiMocks.getMapFeatures.mock.calls.map(([request]) => request.layers) ).toEqual(expect.arrayContaining([["harbours"], ["locks"], ["bridges"]])); expect(result.current.harbours.map((harbour) => harbour.name)).toEqual([ "Testhafen" ]); expect(result.current.locks.map((lock) => lock.name)).toEqual([ "Testschleuse" ]); expect(result.current.bridgeReport?.bridges.map((bridge) => bridge.name)).toEqual([ "Testbrücke" ]); expect(result.current.error).toBeNull(); }); it("keeps working categories when one feature request fails", async () => { apiMocks.getMapFeatures.mockImplementation( async ({ layers }: { layers: string[] }) => { if (layers[0] === "harbours") { throw new Error("Feature-Datenbank fehlt"); } return featureCollection(layers[0]!); } ); const activeRoute = route("partial", 0); const { result } = renderHook(() => useRouteEventFeatures(activeRoute, { airDraftM: 3 }) ); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.harbours).toEqual([]); expect(result.current.locks).toHaveLength(1); expect(result.current.bridgeReport?.bridges).toHaveLength(1); expect(result.current.errors.harbours).toContain( "Häfen nicht erreichbar: Feature-Datenbank fehlt" ); expect(result.current.errors.locks).toBeNull(); expect(result.current.errors.bridges).toBeNull(); }); it("reassesses only bridges when the boat height changes", async () => { apiMocks.getMapFeatures.mockImplementation( async ({ layers }: { layers: string[] }) => featureCollection(layers[0]!) ); const activeRoute = route("height", 0); const { result, rerender } = renderHook( ({ airDraftM }) => useRouteEventFeatures(activeRoute, { airDraftM }), { initialProps: { airDraftM: 3 } } ); await waitFor(() => expect(result.current.loading).toBe(false)); apiMocks.getMapFeatures.mockClear(); rerender({ airDraftM: 3.5 }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1); expect(apiMocks.getMapFeatures).toHaveBeenCalledWith( expect.objectContaining({ layers: ["bridges"] }) ); expect(result.current.harbours).toHaveLength(1); expect(result.current.locks).toHaveLength(1); expect(result.current.bridgeReport?.requiredAirDraftM).toBe(3.5); }); it("aborts stale category requests on a route change and exposes only the new route", async () => { const staleSignals: AbortSignal[] = []; const currentSignals: AbortSignal[] = []; let staleCalls = 0; apiMocks.getMapFeatures.mockImplementation( ({ layers, signal }: { layers: string[]; signal: AbortSignal }) => { if (staleCalls < 3) { staleCalls += 1; staleSignals.push(signal); return new Promise(() => undefined); } currentSignals.push(signal); return Promise.resolve(featureCollection(layers[0]!, 0.5)); } ); const firstRoute = route("first", 0); const secondRoute = route("second", 0.5); const { result, rerender, unmount } = renderHook( ({ activeRoute }) => useRouteEventFeatures(activeRoute, { airDraftM: 3 }), { initialProps: { activeRoute: firstRoute } } ); await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3)); rerender({ activeRoute: secondRoute }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(6); expect(staleSignals).toHaveLength(3); expect(staleSignals.every((signal) => signal.aborted)).toBe(true); expect(result.current.harbours[0]?.coordinate.lon).toBeCloseTo(0.7); expect(result.current.locks[0]?.coordinate.lon).toBeCloseTo(0.8); expect(result.current.bridgeReport?.bridges[0]?.coordinate.lon).toBeCloseTo(0.9); unmount(); expect(currentSignals).toHaveLength(3); expect(currentSignals.every((signal) => signal.aborted)).toBe(true); }); }); function featureCollection(layer: string, offset = 0) { const fixtures = { harbours: { type: "Feature" as const, id: `harbour-${offset}`, properties: { layer: "harbours", name: "Testhafen" }, geometry: { type: "Point" as const, coordinates: [0.2 + offset, 0] } }, locks: { type: "Feature" as const, id: `lock-${offset}`, properties: { layer: "locks", name: "Testschleuse" }, geometry: { type: "Point" as const, coordinates: [0.3 + offset, 0] } }, bridges: { type: "Feature" as const, id: `bridge-${offset}`, properties: { layer: "bridges", name: "Testbrücke", clearance_m: 4 }, geometry: { type: "Point" as const, coordinates: [0.4 + offset, 0] } } }; return { type: "FeatureCollection" as const, features: [fixtures[layer as keyof typeof fixtures]] }; } function route(id: string, offset: number): RouteResult { return { id, geometry: { type: "LineString", coordinates: [ [offset, 0], [offset + 1, 0] ] }, distanceNm: 60, eta: null, warnings: [], dataSources: ["test"], routingMode: "fairway" }; }