175 lines
9.0 KiB
TypeScript
175 lines
9.0 KiB
TypeScript
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<typeof vi.fn> },
|
|
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<typeof createMap>, 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)
|
|
});
|
|
}
|