Files
next-tjwater-frontend/src/features/workbench/map/workbench-map-controller.test.ts
T
jiang bf44cc0f25
Generic Container CI/CD / test-build-publish (push) Successful in 59s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 59s
fix: synchronize dynamic GeoServer layer lifecycle
Optional sources became dynamic while dependent labels and controller state remained static, allowing stale requests to restore removed layers. Register dependent layers through the same lifecycle and invalidate target request generations before release.
2026-09-11 18:25:41 +08:00

487 lines
18 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("drops cached features and presentation state when a dynamic source disappears", async () => {
const map = createMap();
map.getLayer.mockReturnValue({} as never);
let collection = pointCollection;
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],
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)
});
}