338 lines
12 KiB
TypeScript
338 lines
12 KiB
TypeScript
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<string, unknown>();
|
|
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<string>();
|
|
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<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)
|
|
});
|
|
}
|