import type { FeatureCollection } from "geojson"; import type { Map as MapLibreMap } from "maplibre-gl"; import { describe, expect, it, vi } from "vitest"; import { WorkbenchMapController } from "./workbench-map-controller"; import { SCADA_ANALYSIS_LAYER_IDS, SCADA_ANALYSIS_SOURCE_ID } from "./scada-analysis"; 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 }); controller.clearHighlight(); expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted"); expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected"); }); 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: "conduits", featureId: "C-1" }); expect(map.fitBounds).toHaveBeenCalledWith([[120, 28], [121, 29]], expect.objectContaining({ padding, maxZoom: 18 })); }); 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 one SCADA point atomically and zooms to level 18", async () => { const source = { setData: vi.fn() }; const map = createMap(source); const controller = createController(map, scadaPointCollection); const result = await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); expect(source.setData).toHaveBeenCalledWith(expect.objectContaining({ features: [expect.objectContaining({ properties: expect.objectContaining({ analysis_label: "MP01 · 高" }) })] })); expect(map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ center: [120.7, 28], zoom: 18 })); expect(result).toEqual({ rendered_ids: ["MP01"], missing_ids: [], level_counts: { high: 1, medium: 0, low: 0, unrated: 0 }, fitted: true }); }); it("restores missing SCADA analysis presentation layers before reporting success", async () => { const source = { setData: vi.fn() }; const map = createMap(source, false); const controller = createController(map, scadaPointCollection); await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); expect(map.addLayer).toHaveBeenCalledTimes(Object.keys(SCADA_ANALYSIS_LAYER_IDS).length); expect(map.addLayer.mock.calls.map(([layer]) => layer.id)).toEqual( Object.values(SCADA_ANALYSIS_LAYER_IDS) ); expect(map.addLayer.mock.invocationCallOrder.at(-1)).toBeLessThan( source.setData.mock.invocationCallOrder[0] ); }); it("fits multiple SCADA points with panel-safe padding and reports missing IDs", async () => { const source = { setData: vi.fn() }; const map = createMap(source); const padding = { top: 50, right: 420, bottom: 50, left: 320 }; const controller = createController(map, scadaPointCollection, padding); const result = await controller.renderScadaAnalysis([ { sensor_id: "MP01", level: "high" }, { sensor_id: "MP02", level: "medium" }, { sensor_id: "MP404", level: "low" } ]); expect(map.fitBounds).toHaveBeenCalledWith([[120.7, 28], [120.72, 28.02]], expect.objectContaining({ padding, maxZoom: 18 })); expect(result.missing_ids).toEqual(["MP404"]); expect(result.level_counts).toEqual({ high: 1, medium: 1, low: 0, unrated: 0 }); }); it("preserves the last valid overlay when all IDs are missing or WFS fails", async () => { const source = { setData: vi.fn() }; const map = createMap(source); let mode: "found" | "missing" | "failed" = "found"; const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => true, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => { if (mode === "failed") throw new Error("network"); return mode === "found" ? scadaPointCollection : { type: "FeatureCollection", features: [] }; } }); await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); const lastValid = source.setData.mock.calls[0][0]; mode = "missing"; await expect(controller.renderScadaAnalysis([{ sensor_id: "MP404", level: "low" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND"); expect(source.setData).toHaveBeenCalledTimes(1); expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]); mode = "failed"; await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("WFS_UNAVAILABLE"); expect(source.setData).toHaveBeenCalledTimes(1); expect(source.setData.mock.calls[0][0]).toBe(lastValid); }); it("replaces the previous overlay and clears it explicitly", async () => { const source = { setData: vi.fn() }; const map = createMap(source); const controller = createController(map, scadaPointCollection); await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); await controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }]); expect(source.setData.mock.calls[1][0].features).toHaveLength(1); expect(source.setData.mock.calls[1][0].features[0].properties.sensor_id).toBe("MP02"); expect(controller.clearScadaAnalysis()).toEqual({ cleared: true }); expect(source.setData.mock.calls[2][0]).toEqual({ type: "FeatureCollection", features: [] }); expect(controller.getSnapshot().scadaAnalysis).toBeNull(); }); it("keeps the existing overlay when the map is not ready", async () => { const source = { setData: vi.fn() }; const map = createMap(source); let ready = true; const controller = new WorkbenchMapController({ getMap: () => map as unknown as MapLibreMap, isReady: () => ready, getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), fetchFeatures: async () => scadaPointCollection }); await controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }]); ready = false; await expect(controller.renderScadaAnalysis([{ sensor_id: "MP02", level: "low" }])).rejects.toThrow("MAP_NOT_READY"); expect(source.setData).toHaveBeenCalledTimes(1); expect(controller.getSnapshot().scadaAnalysis?.rendered_ids).toEqual(["MP01"]); }); }); 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: "C-1" } }] }; const scadaPointCollection: FeatureCollection = { type: "FeatureCollection", features: [ { type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { sensor_id: "MP01" } }, { type: "Feature", geometry: { type: "Point", coordinates: [120.72, 28.02] }, properties: { sensor_id: "MP02" } } ] }; function createMap( scadaSource?: { setData: ReturnType }, analysisLayersReady = true ) { return { easeTo: vi.fn(), fitBounds: vi.fn(), setFeatureState: vi.fn(), removeFeatureState: vi.fn(), getSource: vi.fn((id: string) => id === SCADA_ANALYSIS_SOURCE_ID ? scadaSource : undefined), getLayer: vi.fn((id: string) => analysisLayersReady && Object.values(SCADA_ANALYSIS_LAYER_IDS).includes(id as never) ? { id } : undefined), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn() }; } 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) }); }