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.
This commit is contained in:
2026-07-28 16:39:36 +08:00
parent ade88b4fbf
commit f4318a9b9c
96 changed files with 5480 additions and 1875 deletions
+169 -13
View File
@@ -1,21 +1,177 @@
import type { Map as MapLibreMap } from "maplibre-gl";
import { describe, expect, it, vi } from "vitest";
import { createFlowOverlayState, stopFlowAnimation } from "./flow-overlay";
import {
FLOW_LINE_LAYER_ID,
FLOW_REVERSE_LINE_LAYER_ID,
clearPipeFlowCalculations,
createFlowOverlayState,
getPipeFlowDirection,
replacePipeFlowCalculations,
showFlowOverlay,
updatePipeFlowCalculation
} from "./flow-overlay";
describe("flow overlay lifecycle", () => {
it("starts empty so conduit data can be loaded once per map session", () => {
expect(createFlowOverlayState()).toEqual({ data: null, animationFrameId: null, overlay: null });
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("cancels an active animation frame without clearing cached data", () => {
const cancelAnimationFrame = vi.fn();
vi.stubGlobal("window", { cancelAnimationFrame });
it("creates native forward and reverse layers with a deterministic all-pipe mock", async () => {
const { map, layers } = createMap();
const state = createFlowOverlayState();
state.data = [];
state.animationFrameId = 42;
stopFlowAnimation(state);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(state.animationFrameId).toBeNull();
expect(state.data).toBeTruthy();
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 };
}