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"; import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style"; describe("WorkbenchMapController", () => { it("tags presentation geometry with its verified source without changing manual state or cached features", async () => { const map = createMap(); const controller = createController(map, pointCollection); const data = await controller.queryPresentationFeatures("junctions", ["J-1"], new AbortController().signal); expect(data.features[0].properties).toEqual({ id: "J-1", [PRESENTATION_FEATURE_SOURCE]: "junctions" }); expect(pointCollection.features[0].properties).toEqual({ id: "J-1" }); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(map.easeTo).not.toHaveBeenCalled(); expect(controller.getSnapshot().targets).toEqual([]); }); it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => { const map = createMap(); map.getLayer.mockReturnValue({} as never); const setData = vi.fn(); map.getSource.mockReturnValue({ setData }); let resolveFeatures = (_value: FeatureCollection) => {}; const pendingAnalysis = new Promise((resolve) => { resolveFeatures = resolve; }); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection) }); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); controller.applyLayerGroupStyle("pipes", { color: "#123456" }); const showingFlow = controller.setFlowVisible(true); const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]); controller.clearOverlays(); await showingFlow; resolveFeatures(pointCollection); await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED"); expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] }); expect(controller.getSnapshot()).toMatchObject({ target: null, targets: [], targetResult: null, scadaAnalysis: null, flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"] }); expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted"); expect(map.setLayoutProperty).toHaveBeenCalledWith(FLOW_LINE_LAYER_ID, "visibility", "none"); expect(map.easeTo).not.toHaveBeenCalled(); }); it("queries a batch once, fits all matches, and replaces and clears every highlight", async () => { const map = createMap(); const collection: FeatureCollection = { type: "FeatureCollection", features: [ ...pointCollection.features, { type: "Feature", properties: { id: "J-2" }, geometry: { type: "Point", coordinates: [121, 29] } } ] }; const fetchFeatures = vi.fn(async () => collection); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 48, right: 48, bottom: 48, left: 48 }), fetchFeatures }); await expect(controller.locateFeatures("junctions", ["J-1", "J-2", "J-1", "missing"])).resolves.toEqual({ located_ids: ["J-1", "J-2"], missing_ids: ["missing"], fitted: true }); expect(fetchFeatures).toHaveBeenCalledExactlyOnceWith("junctions", ["J-1", "J-2", "missing"], undefined); expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [121, 29]], expect.objectContaining({ maxZoom: 18 })); for (const id of ["J-1", "J-2"]) { expect(map.setFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, { highlighted: true }); } expect(controller.getSnapshot().targets).toHaveLength(2); await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); for (const id of ["J-1", "J-2"]) { expect(map.removeFeatureState).toHaveBeenCalledWith({ source: "junctions", sourceLayer: "junctions", id }, "highlighted"); } await controller.highlight({ sourceId: "junctions", featureIds: ["J-1", "J-2"] }); map.removeFeatureState.mockClear(); controller.clearHighlight(); expect(map.removeFeatureState).toHaveBeenCalledTimes(2); expect(controller.getSnapshot()).toMatchObject({ targets: [], target: null, targetResult: null }); }); it("keeps highlight-only and zoom-only batch behavior separate", async () => { const map = createMap(); const controller = createController(map, lineCollection); const target = { sourceId: "pipes" as const, featureIds: ["P-1", "missing"] }; await controller.highlight(target); expect(map.fitBounds).not.toHaveBeenCalled(); expect(map.easeTo).not.toHaveBeenCalled(); expect(controller.getSnapshot().targetResult).toMatchObject({ featureIds: ["P-1"], missingIds: ["missing"] }); map.setFeatureState.mockClear(); await controller.zoomToFeature(target); expect(map.fitBounds).toHaveBeenCalledOnce(); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(map.removeFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot().targets).toEqual([{ sourceId: "pipes", featureId: "P-1" }]); }); it("cancels an in-flight batch without applying late highlights or moving the camera", async () => { const map = createMap(); const abort = new AbortController(); let resolveFeatures = (_value: FeatureCollection) => {}; const fetchFeatures = vi.fn(() => new Promise((resolve) => { resolveFeatures = resolve; })); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures }); const locating = controller.locateFeatures("junctions", ["J-1", "J-2"], abort.signal); abort.abort(); resolveFeatures(pointCollection); await expect(locating).rejects.toThrow(); expect(fetchFeatures).toHaveBeenCalledWith("junctions", ["J-1", "J-2"], abort.signal); expect(map.fitBounds).not.toHaveBeenCalled(); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot()).toMatchObject({ pending: false, targets: [] }); }); it("rejects empty and oversized batches without querying or moving", async () => { const map = createMap(); const controller = createController(map, pointCollection); for (const ids of [[], [" "], Array.from({ length: 101 }, (_, i) => `J-${i}`)]) { await expect(controller.locateFeatures("junctions", ids)).rejects.toThrow("INVALID_FEATURE_IDS"); } expect(map.fitBounds).not.toHaveBeenCalled(); expect(map.setFeatureState).not.toHaveBeenCalled(); }); it("returns only resolved IDs and rejects a missing target rather than fitting the whole network", async () => { const map = createMap(); const controller = createController(map, pointCollection); await expect(controller.locateFeatures("junctions", ["J-1", "missing"])).resolves.toEqual({ located_ids: ["J-1"], missing_ids: ["missing"], fitted: true }); expect(map.fitBounds).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ duration: 0 })); await expect(controller.locateFeatures("junctions", ["missing"])).rejects.toThrow("FEATURE_NOT_FOUND"); }); 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("drops cached features and presentation state when a dynamic source disappears", async () => { const map = createMap(); map.getLayer.mockReturnValue({} as never); let collection: FeatureCollection = { ...pointCollection, features: [{ ...pointCollection.features[0], properties: { id: "V-1" } }] }; const fetchFeatures = vi.fn(async () => collection); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures }); const target = { sourceId: "valves" as const, featureId: "V-1" }; await controller.locateAndHighlight(target); controller.applyLayerGroupStyle("valves", { iconMultiplier: 1.5 }); await controller.showValueLabel({ sourceId: "valves", property: "elevation", precision: 1, showValue: true, showId: false, textSize: 12, textColor: "#0F172A" }); controller.syncAvailableSources(["pipes", "junctions", "scada"]); expect(controller.getSnapshot()).toMatchObject({ target: null, valueLabels: {}, styledGroups: [] }); collection = { ...pointCollection, features: [{ ...pointCollection.features[0], properties: { id: "V-1" }, geometry: { type: "Point", coordinates: [121.9, 29.1] } }] }; map.easeTo.mockClear(); await controller.locateAndHighlight(target); expect(fetchFeatures).toHaveBeenCalledTimes(2); expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [121.9, 29.1] })); }); it("ignores an in-flight dynamic-source result from an earlier availability generation", async () => { const map = createMap(); let resolveFeatures = (_collection: FeatureCollection) => {}; const featureResponse = new Promise((resolve) => { resolveFeatures = resolve; }); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => featureResponse }); controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]); const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" }); controller.syncAvailableSources(["pipes", "junctions", "scada"]); controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]); resolveFeatures(pointCollection); await locating; expect(map.easeTo).not.toHaveBeenCalled(); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot().target).toBeNull(); }); it("keeps a newer cached target when an older dynamic-source request resolves", async () => { const map = createMap(); let resolveDynamicFeatures = (_collection: FeatureCollection) => {}; const dynamicFeatureResponse = new Promise((resolve) => { resolveDynamicFeatures = resolve; }); const fetchFeatures = vi.fn(async (sourceId: string) => sourceId === "valves" ? dynamicFeatureResponse : pointCollection ); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures }); const cachedTarget = { sourceId: "junctions" as const, featureId: "J-1" }; await controller.locateAndHighlight(cachedTarget); const locatingDynamicTarget = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" }); await controller.locateAndHighlight(cachedTarget); expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false }); resolveDynamicFeatures(pointCollection); await locatingDynamicTarget; expect(map.easeTo).toHaveBeenCalledTimes(2); expect(controller.getSnapshot()).toMatchObject({ target: cachedTarget, pending: false }); }); it("does not restore a target after its in-flight request is cleared", async () => { const map = createMap(); let resolveFeatures = (_collection: FeatureCollection) => {}; const featureResponse = new Promise((resolve) => { resolveFeatures = resolve; }); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => featureResponse }); const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" }); controller.clearHighlight(); resolveFeatures(pointCollection); await locating; expect(map.easeTo).not.toHaveBeenCalled(); expect(map.setFeatureState).not.toHaveBeenCalled(); expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false }); }); it("reports an unavailable target when its source disappears in flight", async () => { const map = createMap(); let resolveFeatures = (_collection: FeatureCollection) => {}; const featureResponse = new Promise((resolve) => { resolveFeatures = resolve; }); const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => featureResponse }); controller.syncAvailableSources(["pipes", "junctions", "valves", "scada"]); const locating = controller.locateAndHighlight({ sourceId: "valves", featureId: "V-1" }); controller.syncAvailableSources(["pipes", "junctions", "scada"]); resolveFeatures(pointCollection); await locating; expect(controller.getSnapshot()).toMatchObject({ target: null, pending: false, errorCode: "WFS_UNAVAILABLE" }); }); 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) }); }