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:
@@ -1,6 +1,11 @@
|
||||
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", () => {
|
||||
@@ -10,7 +15,8 @@ describe("WorkbenchMapController", () => {
|
||||
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 });
|
||||
controller.clearHighlight();
|
||||
const clearHighlight = controller.clearHighlight;
|
||||
clearHighlight();
|
||||
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.anything(), "highlighted");
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalledWith(expect.anything(), "selected");
|
||||
});
|
||||
@@ -23,6 +29,201 @@ describe("WorkbenchMapController", () => {
|
||||
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/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": "geo_pipes_mat"
|
||||
});
|
||||
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: "geo_pipes_mat",
|
||||
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;
|
||||
@@ -40,11 +241,36 @@ describe("WorkbenchMapController", () => {
|
||||
expect(map.removeFeatureState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports SCADA analysis as unavailable for the water-network data source", async () => {
|
||||
it("renders SCADA analysis through the registered GeoJSON source", async () => {
|
||||
const map = createMap();
|
||||
const controller = createController(map, pointCollection);
|
||||
await expect(controller.renderScadaAnalysis([{ sensor_id: "MP01", level: "high" }])).rejects.toThrow("SCADA_FEATURES_NOT_FOUND");
|
||||
expect(controller.getSnapshot().errorCode).toBe("SCADA_FEATURES_NOT_FOUND");
|
||||
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: { sensor_id: "MP01" }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.renderScadaAnalysis([{ sensor_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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,9 +278,15 @@ const pointCollection: FeatureCollection = { type: "FeatureCollection", features
|
||||
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(), getLayer: vi.fn(), addLayer: vi.fn(), setPaintProperty: vi.fn(), setLayoutProperty: vi.fn(), removeControl: vi.fn()
|
||||
getSource: vi.fn(), 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);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user