Files
next-tjwater-frontend/src/features/workbench/map/flow-overlay.test.ts
T
jiang f4318a9b9c feat: refine workbench visuals and map controls
Unify the Agent history extension with the header acrylic surface, preserve the full conversation body, and consolidate shared control and status styling.

Restore map flow and SCADA controller behavior, remove obsolete rendering paths, and extend regression coverage. Button press coverage now releases outside the target so state assertions cannot accidentally toggle the control.
2026-07-28 16:39:36 +08:00

178 lines
5.7 KiB
TypeScript

import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import {
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID,
clearPipeFlowCalculations,
createFlowOverlayState,
getPipeFlowDirection,
replacePipeFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation
} from "./flow-overlay";
describe("flow overlay", () => {
it("classifies signed velocities relative to pipe geometry", () => {
expect(getPipeFlowDirection(2.5)).toBe(1);
expect(getPipeFlowDirection(-0.01)).toBe(-1);
expect(getPipeFlowDirection(0)).toBe(0);
expect(getPipeFlowDirection(Number.NaN)).toBe(0);
});
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
const { map, layers } = createMap();
const state = createFlowOverlayState();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
expect(layers.get(FLOW_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
paint: { "line-pattern": "workbench-network-flow-pattern-forward" }
});
expect(layers.get(FLOW_REVERSE_LINE_LAYER_ID)).toMatchObject({
source: "pipes",
"source-layer": "geo_pipes_mat",
paint: { "line-pattern": "workbench-network-flow-pattern-reverse" }
});
expect([...layers.keys()].some((id) => id.includes("arrows"))).toBe(false);
expect(
JSON.stringify((layers.get(FLOW_LINE_LAYER_ID) as { paint: Record<string, unknown> }).paint["line-opacity"])
).toContain('["%",["to-number",["id"],0],2]');
});
it("updates one pipe direction without rebuilding its vector source", () => {
const { map } = createMap();
const state = createFlowOverlayState();
const first = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: -2.4
});
const second = updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: 1.2
});
expect(map.setFeatureState).toHaveBeenNthCalledWith(1, {
source: "pipes",
sourceLayer: "geo_pipes_mat",
id: "P-1"
}, {
flowDirection: -1
});
expect(map.setFeatureState).toHaveBeenNthCalledWith(2, expect.anything(), {
flowDirection: 1
});
expect(first).toMatchObject({ total: 1, reverse: 1 });
expect(second).toMatchObject({ total: 1, forward: 1 });
expect(map.getSource).not.toHaveBeenCalled();
});
it("replaces a snapshot and clears only stale flow state keys", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-old",
velocity: 1
});
map.removeFeatureState.mockClear();
const summary = await replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
[
{ pipeId: "P-new", velocity: -3 },
{ pipeId: "P-new", velocity: 4 },
{ pipeId: "", velocity: 1 }
]
);
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({
id: "P-old"
}), "flowDirection");
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "highlighted");
expect(summary).toEqual({
total: 1,
forward: 1,
reverse: 0,
stopped: 0,
invalid: 1
});
});
it("returns to the all-pipe mock after clearing explicit calculations", async () => {
const { map } = createMap();
const state = createFlowOverlayState();
await showFlowOverlay(map as unknown as MapLibreMap, state, true);
updatePipeFlowCalculation(map as unknown as MapLibreMap, state, {
pipeId: "P-1",
velocity: 1
});
await clearPipeFlowCalculations(map as unknown as MapLibreMap, state);
const latestOpacity = map.setPaintProperty.mock.calls
.filter(([, property]) => property === "line-opacity")
.at(-1)?.[2];
expect(JSON.stringify(latestOpacity)).toContain('["%",["to-number",["id"],0],2]');
});
it("applies large snapshots in animation-frame batches", async () => {
const callbacks: FrameRequestCallback[] = [];
vi.stubGlobal("window", {
matchMedia: () => ({ matches: false }),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callbacks.push(callback);
return callbacks.length;
}),
cancelAnimationFrame: vi.fn()
});
const { map } = createMap();
const state = createFlowOverlayState();
const records = Array.from({ length: 2_001 }, (_, index) => ({
pipeId: index,
velocity: index % 2 === 0 ? 1 : -1
}));
const pending = replacePipeFlowCalculations(
map as unknown as MapLibreMap,
state,
records
);
expect(map.setFeatureState).toHaveBeenCalledTimes(2_000);
callbacks.shift()?.(16);
const summary = await pending;
expect(map.setFeatureState).toHaveBeenCalledTimes(2_001);
expect(summary.total).toBe(2_001);
vi.unstubAllGlobals();
});
});
function createMap() {
const layers = new Map<string, unknown>();
const images = new Set<string>();
const map = {
addImage: vi.fn((id: string) => {
images.add(id);
}),
addLayer: vi.fn((layer: { id: string }) => {
layers.set(layer.id, layer);
return map;
}),
getLayer: vi.fn((id: string) => layers.get(id)),
getSource: vi.fn(),
hasImage: vi.fn((id: string) => images.has(id)),
removeFeatureState: vi.fn(),
setFeatureState: vi.fn(),
setLayoutProperty: vi.fn(),
setPaintProperty: vi.fn((id: string, property: string, value: unknown) => {
const layer = layers.get(id) as { paint?: Record<string, unknown> } | undefined;
if (layer?.paint) layer.paint[property] = value;
}),
triggerRepaint: vi.fn()
};
return { map, layers };
}