import "@testing-library/jest-dom/vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AppConfig, RouteResult } from "@watermaps/shared"; import { MarineFeatureInfo, type MarineFeatureDetails } from "../src/components/MarineFeatureInfo"; const maplibreState = vi.hoisted(() => ({ current: null as any, markerConstructions: 0 })); const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() })); vi.mock("../src/api", () => apiMocks); vi.mock("maplibre-gl", () => { class MockLngLatBounds { extend = vi.fn(() => this); } class MockMap { handlers = new globalThis.Map void>>(); sources = new globalThis.Map< string, { setData: ReturnType; getClusterExpansionZoom: ReturnType; } >(); sourceDefinitions = new globalThis.Map(); layers = new Set(); layerDefinitions = new globalThis.Map(); renderedFeatures: any[] = []; zoom = 13; container: HTMLElement; fitBounds = vi.fn(() => this); easeTo = vi.fn(() => this); flyTo = vi.fn(() => this); setLayoutProperty = vi.fn(); moveLayer = vi.fn(); remove = vi.fn(); constructor(options: { container: HTMLElement }) { this.container = options.container; this.container.addEventListener("click", () => { this.emit("click", { lngLat: { lng: 7.8, lat: 51.7 }, point: { x: 10, y: 10 } }); }); maplibreState.current = this; } addControl() { return this; } on(eventName: string, handler: (event?: any) => void) { this.handlers.set(eventName, new Set([...(this.handlers.get(eventName) ?? []), handler])); return this; } off(eventName: string, handler: (event?: any) => void) { this.handlers.get(eventName)?.delete(handler); return this; } emit(eventName: string, event?: any) { for (const handler of this.handlers.get(eventName) ?? []) { handler(event); } } addSource(id: string, definition: any) { this.sourceDefinitions.set(id, definition); this.sources.set(id, { setData: vi.fn(), getClusterExpansionZoom: vi.fn(async () => 15) }); } getSource(id: string) { return this.sources.get(id); } addLayer(layer: { id: string }) { this.layers.add(layer.id); this.layerDefinitions.set(layer.id, layer); } getLayer(id: string) { return this.layers.has(id) ? { id } : undefined; } getCanvas() { return this.container; } getCanvasContainer() { return this.container; } getContainer() { return this.container; } unproject() { return { lng: 7.8, lat: 51.7 }; } project() { return { x: 10, y: 10 }; } queryRenderedFeatures() { return this.renderedFeatures; } isStyleLoaded() { return true; } getZoom() { return this.zoom; } getBounds() { return { getWest: () => 7, getSouth: () => 51, getEast: () => 9, getNorth: () => 54 }; } } class MockMarker { element: HTMLElement; constructor(options: { element: HTMLElement }) { maplibreState.markerConstructions += 1; this.element = options.element; } setLngLat() { return this; } addTo(map: MockMap) { map.container.append(this.element); return this; } remove() { this.element.remove(); return this; } } class MockControl {} return { default: { Map: MockMap, Marker: MockMarker, LngLatBounds: MockLngLatBounds, AttributionControl: MockControl, ScaleControl: MockControl } }; }); import { MapView } from "../src/components/MapView"; const config: AppConfig = { appName: "Watermaps", region: "Test", disclaimer: "Test", featureFlags: {}, layers: [ { id: "base", name: "Basiskarte", kind: "style", url: "https://example.test/style.json", attribution: "Test", defaultVisible: true } ], attribution: [] }; const marineFeatures = [ { type: "Feature" as const, id: "lock-1", geometry: { type: "Point" as const, coordinates: [7.61, 52.01] }, properties: { layer: "locks", name: "Schleuse Nord", "contact:phone": "+49 123 456", website: "https://schleuse.example", email: "schleuse@example.test", vhf_channel: "Kanal 20", opening_hours: "Mo-Su 06:00-22:00", operator: "WSV", "addr:street": "Am Kanal", "addr:housenumber": "1", "addr:postcode": "12345", "addr:city": "Hafenstadt", source: "OSM/Geofabrik" } }, { type: "Feature" as const, id: "harbour-1", geometry: { type: "Point" as const, coordinates: [7.72, 51.82] }, properties: { layer: "harbours", name: "Stadthafen", source: "Hafenbetreiber" } } ]; beforeEach(() => { maplibreState.current = null; maplibreState.markerConstructions = 0; apiMocks.getMapFeatures.mockReset(); apiMocks.getMapFeatures.mockImplementation(async ({ layers }: { layers: string[] }) => ({ type: "FeatureCollection", features: marineFeatures.filter((feature) => layers.includes(feature.properties.layer)) })); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); describe("MapView marine feature information", () => { it("loads clustered lock and harbour info layers only at a close zoom level", async () => { render( ); maplibreState.current.zoom = 9; await act(async () => maplibreState.current.emit("load")); await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalled()); expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument(); expect(apiMocks.getMapFeatures).toHaveBeenLastCalledWith( expect.objectContaining({ layers: expect.not.arrayContaining(["locks", "harbours"]) }) ); expect(maplibreState.current.sourceDefinitions.get("marine-contact-pois")).toEqual( expect.objectContaining({ type: "geojson", cluster: true, clusterMaxZoom: 14 }) ); expect(maplibreState.current.layerDefinitions.get("lock-info-circles")).toEqual( expect.objectContaining({ type: "circle", minzoom: 12, source: "marine-contact-pois" }) ); expect(maplibreState.current.layerDefinitions.get("contact-cluster-count")).toEqual( expect.objectContaining({ type: "symbol", minzoom: 12, source: "marine-contact-pois" }) ); apiMocks.getMapFeatures.mockClear(); maplibreState.current.zoom = 12; await act(async () => maplibreState.current.emit("zoomend")); await act(async () => maplibreState.current.emit("moveend")); expect(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument(); expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1); expect(maplibreState.markerConstructions).toBe(0); expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0); maplibreState.current.zoom = 11; await act(async () => maplibreState.current.emit("zoom")); expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument(); }); it("opens a rendered POI before route-coordinate picking and keeps static accessible buttons", async () => { const onPickCoordinate = vi.fn(); render( ); await act(async () => maplibreState.current.emit("load")); await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }); expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument(); expect(apiMocks.getMapFeatures).toHaveBeenCalledWith( expect.objectContaining({ layers: expect.arrayContaining(["locks", "harbours"]) }) ); maplibreState.current.renderedFeatures = [marineFeatures[0]]; await act(async () => maplibreState.current.emit("click", { point: { x: 12, y: 18 }, lngLat: { lng: 7.61, lat: 52.01 } }) ); expect(onPickCoordinate).not.toHaveBeenCalled(); expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible(); expect(screen.getByRole("link", { name: /Schleuse Nord anrufen/i })).toHaveAttribute( "href", "tel:+49123456" ); expect(screen.getByRole("link", { name: /Website von Schleuse Nord/i })).toHaveAttribute( "href", "https://schleuse.example/" ); expect(screen.getByText("Kanal 20")).toBeVisible(); expect(screen.getByText("Am Kanal 1, 12345 Hafenstadt")).toBeVisible(); }); it("expands a contact cluster instead of treating it as a picked route coordinate", async () => { const onPickCoordinate = vi.fn(); render( ); await act(async () => maplibreState.current.emit("load")); maplibreState.current.renderedFeatures = [ { type: "Feature", geometry: { type: "Point", coordinates: [7.7, 52] }, properties: { cluster: true, cluster_id: 42, point_count: 12 } } ]; await act(async () => maplibreState.current.emit("click", { point: { x: 20, y: 20 }, lngLat: { lng: 7.7, lat: 52 } }) ); const contactSource = maplibreState.current.getSource("marine-contact-pois"); expect(contactSource.getClusterExpansionZoom).toHaveBeenCalledWith(42); await waitFor(() => expect(maplibreState.current.easeTo).toHaveBeenCalledWith( expect.objectContaining({ center: [7.7, 52], zoom: 15, essential: true }) ) ); expect(onPickCoordinate).not.toHaveBeenCalled(); }); it("removes accessible POIs and closes their panel when a feature layer is disabled", async () => { render( ); await act(async () => maplibreState.current.emit("load")); fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Hafen Stadthafen" })); expect(screen.getByRole("dialog", { name: "Stadthafen" })).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "Layer" })); fireEvent.click(screen.getByRole("checkbox", { name: "Häfen ab Zoom 12" })); await waitFor(() => { expect(screen.queryByRole("button", { name: "Informationen zu Hafen Stadthafen" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "Stadthafen" })).not.toBeInTheDocument(); }); expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeVisible(); }); it("replaces stale static POI controls after the visible map area is refreshed", async () => { render( ); await act(async () => maplibreState.current.emit("load")); fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })); expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible(); apiMocks.getMapFeatures.mockResolvedValue({ type: "FeatureCollection", features: [ { type: "Feature", id: "lock-2", geometry: { type: "Point", coordinates: [8.1, 52.2] }, properties: { layer: "locks", name: "Schleuse Süd", source: "Test" } } ] }); await act(async () => maplibreState.current.emit("moveend")); await waitFor(() => { expect(screen.queryByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).not.toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: "Schleuse Nord" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Süd" })).toBeVisible(); }); }); it("keeps map movement handlers constant for a dense contact response", async () => { apiMocks.getMapFeatures.mockResolvedValue({ type: "FeatureCollection", features: Array.from({ length: 250 }, (_, index) => ({ type: "Feature", id: `lock-${index}`, geometry: { type: "Point", coordinates: [7 + index / 10_000, 52] }, properties: { layer: "locks", name: `Schleuse ${index}` } })) }); render( ); await act(async () => maplibreState.current.emit("load")); await waitFor(() => expect(maplibreState.current.getSource("marine-contact-pois").setData).toHaveBeenCalled()); expect(maplibreState.markerConstructions).toBe(0); expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0); expect(maplibreState.current.handlers.get("moveend")?.size ?? 0).toBe(1); }); it("aborts a superseded feature request", async () => { render( ); await act(async () => maplibreState.current.emit("load")); await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }); let firstSignal: AbortSignal | undefined; apiMocks.getMapFeatures.mockImplementationOnce( ({ signal }: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { firstSignal = signal; signal?.addEventListener("abort", () => reject(new DOMException("Abgebrochen", "AbortError")), { once: true }); }) ); await act(async () => maplibreState.current.emit("moveend")); await waitFor(() => expect(firstSignal).toBeDefined()); apiMocks.getMapFeatures.mockResolvedValueOnce({ type: "FeatureCollection", features: marineFeatures }); await act(async () => maplibreState.current.emit("moveend")); expect(firstSignal?.aborted).toBe(true); await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3)); }); it("fits the map to a newly calculated route", async () => { const stableOnMapReady = vi.fn(); const view = render( ); await act(async () => maplibreState.current.emit("load")); const route: RouteResult = { geometry: { type: "LineString", coordinates: [ [7.18, 53.34], [7.81, 51.68] ] }, distanceNm: 120, eta: null, warnings: [], dataSources: [], routingMode: "fairway" }; view.rerender( ); expect(maplibreState.current.fitBounds).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ maxZoom: 14, essential: true }) ); }); it("focuses a route event requested by the shared navigation workspace", async () => { const stableOnMapReady = vi.fn(); const view = render( ); await act(async () => maplibreState.current.emit("load")); view.rerender( ); expect(maplibreState.current.flyTo).toHaveBeenCalledWith({ center: [7.4, 52.1], zoom: 15, essential: true }); }); it("applies an existing route-event focus after the map style has loaded", async () => { render( ); expect(maplibreState.current.flyTo).not.toHaveBeenCalled(); await act(async () => maplibreState.current.emit("load")); expect(maplibreState.current.flyTo).toHaveBeenCalledWith({ center: [6.9, 53.2], zoom: 13, essential: true }); }); it("draws the live guidance vector and lookahead target without DOM markers", async () => { render( ); await act(async () => maplibreState.current.emit("load")); const definition = maplibreState.current.sourceDefinitions.get("route-guidance"); expect(definition).toEqual(expect.objectContaining({ type: "geojson" })); expect(definition.data.features).toHaveLength(2); expect(definition.data.features[0].geometry).toEqual({ type: "LineString", coordinates: [[7, 52], [7.01, 52.001]] }); expect(maplibreState.current.layerDefinitions.get("route-guidance-line")).toEqual( expect.objectContaining({ type: "line", source: "route-guidance" }) ); expect(maplibreState.markerConstructions).toBe(0); }); }); describe("MapView anchor watch", () => { it("renders one geodesic metre-based alarm ring and updates it without markers", async () => { const onPickCoordinate = vi.fn(); const onMapReady = vi.fn(); const { rerender } = render( ); await act(async () => maplibreState.current.emit("load")); const initial = maplibreState.current.sourceDefinitions.get("anchor-watch").data; const radius = initial.features.find((feature: any) => feature.properties.kind === "radius"); expect(radius.geometry.type).toBe("Polygon"); expect(radius.geometry.coordinates[0]).toHaveLength(65); expect(initial.features.filter((feature: any) => feature.properties.kind === "anchor")).toHaveLength(1); expect(initial.features.filter((feature: any) => feature.properties.kind === "distance")).toHaveLength(1); expect(maplibreState.current.layerDefinitions.get("anchor-watch-radius-fill")).toEqual( expect.objectContaining({ type: "fill", source: "anchor-watch" }) ); expect(maplibreState.current.easeTo).toHaveBeenCalledWith( expect.objectContaining({ center: [7.1, 53.2], zoom: 15 }) ); expect(maplibreState.markerConstructions).toBe(0); rerender( ); const source = maplibreState.current.sources.get("anchor-watch"); await waitFor(() => expect(source.setData).toHaveBeenCalled()); const updated = source.setData.mock.calls.at(-1)?.[0]; expect(updated.features.every((feature: any) => feature.properties.alarm === true)).toBe(true); expect(updated.features.find((feature: any) => feature.properties.kind === "radius").geometry.coordinates[0]).toHaveLength(65); }); }); describe("MapView waypoints", () => { it("creates a labelled waypoint source, updates it, and shows the waypoint picking hint", async () => { const stableOnPickCoordinate = vi.fn(); const stableOnMapReady = vi.fn(); const view = render( ); expect(screen.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible(); await act(async () => maplibreState.current.emit("load")); expect(maplibreState.current.sourceDefinitions.get("waypoints")).toEqual({ type: "geojson", data: { type: "FeatureCollection", features: [ { type: "Feature", properties: { label: "Z1" }, geometry: { type: "Point", coordinates: [7.25, 52.8] } }, { type: "Feature", properties: { label: "Z2" }, geometry: { type: "Point", coordinates: [7.5, 52.1] } } ] } }); expect(maplibreState.current.layerDefinitions.get("waypoint-label")).toEqual( expect.objectContaining({ source: "waypoints" }) ); const waypointSource = maplibreState.current.getSource("waypoints"); view.rerender( ); expect(waypointSource?.setData).toHaveBeenLastCalledWith({ type: "FeatureCollection", features: [ { type: "Feature", properties: { label: "Z1" }, geometry: { type: "Point", coordinates: [7.65, 52.4] } } ] }); }); }); describe("MarineFeatureInfo", () => { it("summarizes missing contact information without rendering empty data rows and closes with Escape", () => { const onClose = vi.fn(); const feature: MarineFeatureDetails = { id: "harbour-missing", layer: "harbours", name: "Unbemannter Hafen", typeLabel: "Hafen", coordinate: { lat: 51.7, lon: 7.8 }, phone: null, website: null, email: null, vhf: null, openingHours: null, operator: null, address: null, source: null, updatedAt: null }; render(); expect(screen.getByText("Keine direkten Kontaktdaten hinterlegt.")).toBeVisible(); expect(screen.queryByText("Nicht hinterlegt")).not.toBeInTheDocument(); expect(screen.getByText("Daten & Quelle")).toBeVisible(); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalledTimes(1); }); it("links the enrichment provenance and explains merged map objects", () => { const feature: MarineFeatureDetails = { id: "lock-enriched", layer: "locks", name: "Schleuse Werries", typeLabel: "Schleuse", coordinate: { lat: 51.69508, lon: 7.86708 }, phone: "+49 2381 9019-290", website: null, email: null, vhf: "22", openingHours: null, operator: "WSV", address: null, source: "EuRIS + OpenStreetMap", sourceUrl: "https://www.eurisportal.eu/visuris/api/Locks_v2/GetLock?isrs=DEHMM00301LOCKS00404", updatedAt: "2026-07-20T10:00:00.000Z", memberCount: 4 }; render(); expect(screen.getByRole("link", { name: /Schleuse Werries anrufen/i })).toHaveAttribute( "href", "tel:+4923819019290" ); fireEvent.click(screen.getByText("Daten & Quelle")); expect(screen.getByRole("link", { name: "EuRIS + OpenStreetMap" })).toHaveAttribute( "href", expect.stringContaining("DEHMM00301LOCKS00404") ); expect(screen.getByText("4 Kartenobjekte")).toBeVisible(); }); });