import type { FeatureCollection } from "geojson"; import type { Map as MapLibreMap } from "maplibre-gl"; import { describe, expect, it, vi } from "vitest"; import { FLOW_LINE_LAYER_ID, createFlowOverlayState, showFlowOverlay } from "./flow-overlay"; import { WorkbenchMapController } from "./workbench-map-controller"; describe("WorkbenchMapController", () => { it("locates points with zoom 18 and keeps selected state untouched", async () => { const map = createMap(); const controller = createController(map, pointCollection); await controller.locateAndHighlight({ sourceId: "junctions", featureId: "J-1" }); expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 })); expect(map.setFeatureState).toHaveBeenCalledWith(expect.objectContaining({ source: "junctions", id: "J-1" }), { highlighted: true }); const clearHighlight = controller.clearHighlight; clearHighlight(); expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted"); expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected"); }); it("clears a stale highlight without touching a removed source", async () => { const map = createMap(); const controller = createController(map, pointCollection); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); map.getSource.mockReturnValue(undefined); controller.clearHighlight(); expect(map.removeFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot().target).toBeNull(); }); it("ignores a resolved highlight after its source has been removed", async () => { const map = createMap(); map.getSource.mockReturnValue(undefined); const controller = createController(map, pointCollection); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot().target).toBeNull(); expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND"); }); it("does not move to a cached feature after its source has been removed", async () => { const map = createMap(); const controller = createController(map, pointCollection); const target = { sourceId: "junctions" as const, featureId: "J-1" }; await controller.locateAndHighlight(target); map.getSource.mockReturnValue(undefined); await controller.locateAndHighlight(target); expect(map.easeTo).toHaveBeenCalledTimes(1); expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND"); }); it("fits line bounds and uses responsive workbench padding", async () => { const map = createMap(); const padding = { top: 50, right: 420, bottom: 50, left: 320 }; const controller = createController(map, lineCollection, padding); await controller.locateAndHighlight({ sourceId: "pipes", featureId: "P-1" }); expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 })); }); it("zooms to a feature without changing its highlight state", async () => { const map = createMap(); const controller = createController(map, pointCollection); await controller.zoomToFeature({ sourceId: "junctions", featureId: "J-1" }); expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 })); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot().target).toBeNull(); }); it("queries GeoServer WFS directly when no feature adapter is supplied", async () => { const map = createMap(); const fetchMock = vi.fn(async (_input: RequestInfo | URL) => new Response(JSON.stringify(pointCollection), { status: 200, headers: { "Content-Type": "application/json" } })); vi.stubGlobal("fetch", fetchMock); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }) }); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); expect(requestUrl.pathname).toBe("/geoserver/tjwater_next/ows"); expect(requestUrl.searchParams.get("request")).toBe("GetFeature"); expect(requestUrl.searchParams.get("cql_filter")).toBe("\"id\" IN ('J-1')"); expect(map.setFeatureState).toHaveBeenCalled(); vi.unstubAllGlobals(); }); it("renders flow from the complete pipes vector source", async () => { const map = createMap(); const layers = new Map(); map.getLayer.mockImplementation((id: string) => layers.get(id)); map.addLayer.mockImplementation((layer: { id: string }) => { layers.set(layer.id, layer); return map as never; }); vi.stubGlobal("window", { matchMedia: () => ({ matches: true }), requestAnimationFrame: vi.fn(), cancelAnimationFrame: vi.fn() }); await showFlowOverlay(map as unknown as MapLibreMap, createFlowOverlayState(), true); expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({ source: "pipes", "source-layer": "pipes" }); expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false); vi.unstubAllGlobals(); }); it("updates signed pipe velocity and exposes direction counts", () => { const map = createMap(); const controller = createController(map, pointCollection); controller.updatePipeFlowCalculation({ pipeId: "P-1", velocity: -1.8 }); expect(map.setFeatureState).toHaveBeenCalledWith( expect.objectContaining({ source: "pipes", sourceLayer: "pipes", id: "P-1" }), { flowDirection: -1 } ); expect(controller.getSnapshot().flowSummary).toMatchObject({ total: 1, forward: 0, reverse: 1, stopped: 0 }); }); it("replaces complete pipe calculation snapshots", async () => { const map = createMap(); const controller = createController(map, pointCollection); await controller.replacePipeFlowCalculations([ { pipeId: "P-1", velocity: 2 }, { pipeId: "P-2", velocity: 0 }, { pipeId: "", velocity: 4 } ]); expect(controller.getSnapshot()).toMatchObject({ pending: false, flowSummary: { total: 2, forward: 1, reverse: 0, stopped: 1, invalid: 1 } }); }); it("keeps MVT labels per group and supports group or global clearing", async () => { const map = createMap(); map.getLayer.mockImplementation((id: string) => id.startsWith("workbench-value-label-") ? {} as never : undefined); const controller = createController(map, pointCollection); await controller.showValueLabel({ sourceId: "pipes", property: "diameter", precision: 0, unit: "mm", showValue: true, showId: false, textSize: 12, textColor: "#0F172A" }); map.setLayoutProperty.mockClear(); await controller.showValueLabel({ sourceId: "junctions", property: "pressure", precision: 1, unit: "m", showValue: true, showId: true, textSize: 13, textColor: "#0F172A" }); expect(map.setLayoutProperty).toHaveBeenCalledWith( "workbench-value-label-junctions", "text-field", expect.arrayContaining(["concat"]) ); expect(map.setLayoutProperty).toHaveBeenCalledWith( "workbench-value-label-junctions", "visibility", "visible" ); expect(map.setFilter).toHaveBeenCalledWith( "workbench-value-label-junctions", ["has", "pressure"] ); expect(controller.getSnapshot().valueLabels.junctions).toMatchObject({ sourceId: "junctions", property: "pressure", showId: true }); expect(controller.getSnapshot().valueLabels.pipes).toMatchObject({ sourceId: "pipes", property: "diameter" }); expect(map.setLayoutProperty).not.toHaveBeenCalledWith( "workbench-value-label-pipes", "visibility", "none" ); controller.clearValueLabel("junctions"); expect(map.setLayoutProperty).toHaveBeenCalledWith( "workbench-value-label-junctions", "visibility", "none" ); expect(controller.getSnapshot().valueLabels).toEqual({ pipes: expect.objectContaining({ property: "diameter" }) }); const clearValueLabel = controller.clearValueLabel; clearValueLabel(); expect(map.setLayoutProperty).toHaveBeenCalledWith( "workbench-value-label-pipes", "visibility", "none" ); expect(controller.getSnapshot().valueLabels).toEqual({}); }); it("applies icon scaling to symbol-backed point groups", () => { const map = createMap(); map.getLayer.mockReturnValue({} as never); const controller = createController(map, pointCollection); controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.8 }); expect(map.setLayoutProperty).toHaveBeenCalledWith( "valves-symbol", "icon-size", expect.arrayContaining(["*", expect.anything(), 1.8]) ); }); it("does not clear the last valid highlight when a target is missing", async () => { const map = createMap(); let collection = pointCollection; const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => collection }); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); collection = { type: "FeatureCollection", features: [] }; await controller.highlight({ sourceId: "junctions", featureId: "missing" }); expect(controller.getSnapshot().target).toEqual({ sourceId: "junctions", featureId: "J-1" }); expect(controller.getSnapshot().errorCode).toBe("FEATURE_NOT_FOUND"); expect(map.removeFeatureState).not.toHaveBeenCalled(); }); it("renders SCADA analysis through the registered GeoJSON source", async () => { const map = createMap(); const source = { setData: vi.fn() }; map.getSource.mockReturnValue(source); const controller = createController(map, { type: "FeatureCollection", features: [ { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { device_id: "MP01" } } ] }); await expect( controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }]) ).resolves.toMatchObject({ rendered_ids: ["MP01"], fitted: true }); expect(source.setData).toHaveBeenCalled(); expect(controller.getSnapshot()).toMatchObject({ errorCode: null, pending: false, scadaAnalysis: { rendered_ids: ["MP01"], fitted: true } }); }); }); const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] }; const lineCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "LineString", coordinates: [[120, 28], [121, 29]] }, properties: { id: "P-1" } }] }; function createMap() { const images = new Set(); return { easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(), getSource: vi.fn((_id: string): unknown => ({})), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), setFilter: vi.fn(), removeControl: vi.fn(), triggerRepaint: vi.fn(), hasImage: vi.fn((id: string) => images.has(id)), addImage: vi.fn((id: string) => { images.add(id); }) }; } function createController(map: ReturnType, collection: FeatureCollection, padding = { top: 48, right: 48, bottom: 48, left: 48 }) { return new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => padding, fetchFeatures: vi.fn(async () => collection) }); }