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

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