995 lines
30 KiB
TypeScript
995 lines
30 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);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|