Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,866 @@
|
||||
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;
|
||||
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(
|
||||
<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: [],
|
||||
minKnownDepthM: null,
|
||||
unknownDepthRatio: 1,
|
||||
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("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={null}
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user