Files
next-tjwater-drainage-frontend/features/workbench/map/workbench-map-controller.test.ts
T

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)
});
}