Files
watermaps/apps/web/tests/map-view.test.tsx
T
BuTzZ 5994d1e71c
Test and publish container images / test (push) Successful in 2m16s
Test and publish container images / publish (push) Successful in 1m6s
Verschiebemodus eingebaut
2026-08-03 08:31:45 +02:00

1060 lines
32 KiB
TypeScript

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<string, Set<(event?: any) => void>>();
sources = new globalThis.Map<
string,
{
setData: ReturnType<typeof vi.fn>;
getClusterExpansionZoom: ReturnType<typeof vi.fn>;
}
>();
sourceDefinitions = new globalThis.Map<string, any>();
layers = new Set<string>();
layerDefinitions = new globalThis.Map<string, any>();
renderedFeatures: any[] = [];
zoom = 13;
styleLoaded = true;
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 this.styleLoaded;
}
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={route}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.fitBounds).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxZoom: 14, essential: true })
);
});
it("atomically replaces and clears route drawings while other map data is loading", async () => {
const stableOnPickCoordinate = vi.fn();
const stableOnMapReady = vi.fn();
const firstRoute: RouteResult = {
id: "first",
name: "Hauptroute",
geometry: {
type: "LineString",
coordinates: [
[7.1, 53.1],
[7.2, 53.2],
[7.3, 53.3]
]
},
distanceNm: 10,
eta: null,
warnings: [],
dataSources: [],
routingMode: "fairway"
};
const secondRoute: RouteResult = {
...firstRoute,
id: "second",
name: "Alternative 1",
geometry: {
type: "LineString",
coordinates: [
[8.1, 54.1],
[8.2, 54.2],
[8.3, 54.3],
[8.4, 54.4]
]
}
};
const view = render(
<MapView
config={config}
position={{ lat: 53.1, lon: 7.1 }}
accuracyM={5}
startPoint={{ lat: 53.1, lon: 7.1 }}
destination={{ lat: 53.3, lon: 7.3 }}
waypoints={[{ lat: 53.2, lon: 7.2 }]}
pickMode={null}
route={firstRoute}
guidanceActive
guidanceTarget={{ lat: 53.2, lon: 7.2 }}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const routeSource = maplibreState.current.sources.get("route");
const guidanceSource = maplibreState.current.sources.get("route-guidance");
const startSource = maplibreState.current.sources.get("start-point");
const destinationSource = maplibreState.current.sources.get("destination");
const waypointSource = maplibreState.current.sources.get("waypoints");
maplibreState.current.styleLoaded = false;
view.rerender(
<MapView
config={config}
position={{ lat: 54.1, lon: 8.1 }}
accuracyM={5}
startPoint={{ lat: 54.1, lon: 8.1 }}
destination={{ lat: 54.4, lon: 8.4 }}
waypoints={[{ lat: 54.3, lon: 8.3 }]}
pickMode={null}
route={secondRoute}
guidanceActive
guidanceTarget={{ lat: 54.2, lon: 8.2 }}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(routeSource.setData).toHaveBeenLastCalledWith({
type: "Feature",
properties: {},
geometry: secondRoute.geometry
});
view.rerender(
<MapView
config={config}
position={{ lat: 54.1, lon: 8.1 }}
accuracyM={5}
startPoint={null}
destination={null}
waypoints={[]}
pickMode={null}
route={null}
guidanceActive={false}
guidanceTarget={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(routeSource.setData).toHaveBeenLastCalledWith({
type: "Feature",
properties: {},
geometry: { type: "LineString", coordinates: [] }
});
expect(guidanceSource.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: []
});
expect(startSource.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: []
});
expect(destinationSource.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: []
});
expect(waypointSource.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: []
});
});
it("focuses a route event requested by the shared navigation workspace", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "lock:test:1",
coordinate: { lat: 52.1, lon: 7.4 },
zoom: 15
}}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "harbour:early:1",
coordinate: { lat: 53.2, lon: 6.9 },
zoom: 13
}}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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(
<MapView
config={config}
position={{ lat: 52, lon: 7 }}
accuracyM={6}
startPoint={{ lat: 52, lon: 7 }}
destination={{ lat: 52, lon: 7.1 }}
pickMode={null}
route={{
geometry: { type: "LineString", coordinates: [[7, 52], [7.1, 52]] },
distanceNm: 4,
eta: null,
warnings: [],
dataSources: [],
routingMode: "fairway"
}}
guidanceActive
guidanceTarget={{ lat: 52.001, lon: 7.01 }}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
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);
});
it("keeps the GPS position centered while navigation follow mode is active", async () => {
const onFollowPositionChange = vi.fn();
const onMapReady = vi.fn();
const onPickCoordinate = vi.fn();
const view = render(
<MapView
config={config}
position={{ lat: 52, lon: 7 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
navigationActive
followPosition={false}
onFollowPositionChange={onFollowPositionChange}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const followButton = screen.getByRole("button", {
name: "GPS-Folge-Modus aktivieren"
});
expect(followButton).toHaveAttribute("aria-pressed", "false");
fireEvent.click(followButton);
expect(onFollowPositionChange).toHaveBeenCalledWith(true);
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [7, 52],
zoom: 13,
essential: true
});
view.rerender(
<MapView
config={config}
position={{ lat: 52.01, lon: 7.02 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
navigationActive
followPosition
onFollowPositionChange={onFollowPositionChange}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
expect(
screen.getByRole("button", { name: "GPS-Folge-Modus beenden" })
).toHaveAttribute("aria-pressed", "true");
expect(maplibreState.current.easeTo).toHaveBeenLastCalledWith({
center: [7.02, 52.01],
zoom: 13,
duration: 500,
essential: true
});
await act(async () => maplibreState.current.emit("dragstart"));
expect(onFollowPositionChange).toHaveBeenLastCalledWith(false);
});
});
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(
<MapView
config={config}
position={{ lat: 53.2001, lon: 7.1001 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={80}
anchorWatchActive
anchorAlarm={false}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
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(
<MapView
config={config}
position={{ lat: 53.2015, lon: 7.1 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={100}
anchorWatchActive
anchorAlarm
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[
{ lat: 52.8, lon: 7.25 },
{ lat: 52.1, lon: 7.5 }
]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
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(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[{ lat: 52.4, lon: 7.65 }]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
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(<MarineFeatureInfo feature={feature} onClose={onClose} />);
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(<MarineFeatureInfo feature={feature} onClose={vi.fn()} />);
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();
});
});