612 lines
25 KiB
TypeScript
612 lines
25 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";
|
|
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<FeatureCollection>((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<FeatureCollection>((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<FeatureCollection>((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<FeatureCollection>((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<FeatureCollection>((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<FeatureCollection>((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<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)
|
|
});
|
|
}
|