feat: extend map targeting and overlay controls

Support batch feature targeting and clear map highlights and overlays from the More panel. Remove misleading action chevrons and unused configuration export entries.

Validated the isolated patch with lint, typecheck, 36 focused tests and 5 browser cases using mocked data.
This commit is contained in:
2026-10-28 14:47:33 +08:00
parent e57e0f099f
commit 5b710d1247
17 changed files with 666 additions and 202 deletions
+84
View File
@@ -0,0 +1,84 @@
import { expect, test, type Page } from "@playwright/test";
import type { GeoJSONSource } from "maplibre-gl";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const sourceCount = (page: Page, id: string) => page.evaluate(async (sourceId) => {
const source = window.__waterNetworkMap?.getSource(sourceId) as GeoJSONSource | undefined;
const data = await source?.getData();
return data && typeof data !== "string" && data.type === "FeatureCollection" ? data.features.length : 0;
}, id);
test("More clears controller and investigation overlays while keeping the investigation chart", async ({ page }) => {
const task = investigationFixture(78, "清理地图覆盖层验证");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
const presentation = page.getByTestId("investigation-presentation-panel");
await expect(presentation.getByText("跟随所选调查", { exact: true })).toBeVisible();
await mock.emit(task, { action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "pulse" });
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(1);
await mock.emit(task, { action: "set_overlay", overlay_id: "clear-test", title: "待清理范围", mode: "geometry", evidence_id: "synthetic",
data: { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.1] } }] } });
await expect(presentation.getByRole("region", { name: "调查覆盖层" })).toBeVisible();
await mock.emit(task, { action: "open_chart", chart_id: "retain-chart", evidence_refs: ["synthetic"],
chart: { title: "保留的调查曲线", chart_type: "line", x_data: ["10:00", "10:15"], series: [{ name: "P1", data: [20, 21] }] } });
const chart = presentation.locator('[data-investigation-chart="retain-chart"]');
await expect(chart).toBeVisible();
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const dev = page.getByTestId("map-dev-panel");
await dev.getByRole("combobox", { name: "业务源" }).click();
await page.getByRole("option", { name: "SCADA", exact: true }).click();
await dev.getByLabel("要素 ID(可多个)").fill("P1");
await dev.getByRole("button", { name: "仅高亮", exact: true }).click();
await dev.locator("summary").filter({ hasText: "水流调试" }).click();
await dev.getByRole("button", { name: "开启水流", exact: true }).click();
await dev.locator("summary").filter({ hasText: "数值标注" }).click();
await dev.getByRole("button", { name: "应用标注", exact: true }).click();
await expect.poll(() => page.evaluate(() => window.__waterNetworkMap?.getFeatureState({ source: "scada", id: "P1" }).highlighted)).toBe(true);
await dev.getByRole("button", { name: "关闭 Dev Panel" }).click();
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click();
const more = page.getByRole("region", { name: "更多", exact: true });
await more.getByRole("button", { name: /清空所有高亮和覆盖层/ }).click();
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(0);
await expect.poll(() => sourceCount(page, "investigation-presentation-overlay")).toBe(0);
await expect.poll(() => page.evaluate(() => ({
highlighted: window.__waterNetworkMap?.getFeatureState({ source: "scada", id: "P1" }).highlighted ?? false,
flow: window.__waterNetworkMap?.getLayoutProperty("workbench-network-flow-forward", "visibility"),
labels: window.__waterNetworkMap?.getLayoutProperty("workbench-value-label-pipes", "visibility")
}))).toEqual({ highlighted: false, flow: "none", labels: "none" });
await expect(presentation.getByRole("region", { name: "调查覆盖层" })).toHaveCount(0);
await expect(chart).toBeVisible();
await page.screenshot({ path: "/tmp/map-clear-overlays-desktop.png" });
// A later explicit command still works; clearing is a one-time display operation.
await mock.emit(task, { action: "highlight_features", layer: "scada", feature_ids: ["P1"], effect: "steady" });
await expect.poll(() => sourceCount(page, "investigation-presentation-highlights")).toBe(1);
});
test("mobile More clears drawing and measurement data and remains usable when already empty", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await page.getByRole("button", { name: "测量:距离与范围测量", exact: true }).click();
await page.getByRole("region", { name: "测量", exact: true }).getByRole("button", { name: /开始测量/ }).click();
await page.locator("canvas.maplibregl-canvas").click({ position: { x: 100, y: 175 } });
await expect.poll(() => sourceCount(page, "workbench-measurement")).toBeGreaterThan(0);
await page.getByRole("button", { name: "标注:管理地图标注", exact: true }).click();
await page.getByRole("region", { name: "标注", exact: true }).getByRole("button", { name: "点标注", exact: true }).click();
await page.locator("canvas.maplibregl-canvas").click({ position: { x: 100, y: 175 } });
await expect.poll(() => sourceCount(page, "workbench-drawing-features")).toBe(1);
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click();
const more = page.getByRole("region", { name: "更多", exact: true });
const clear = more.getByRole("button", { name: /清空所有高亮和覆盖层/ });
await expect(clear).toBeVisible();
await clear.click();
await expect.poll(() => sourceCount(page, "workbench-measurement")).toBe(0);
await expect.poll(() => sourceCount(page, "workbench-drawing-features")).toBe(0);
await clear.click();
await expect(clear).toBeEnabled();
const box = await more.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
await page.screenshot({ path: "/tmp/map-clear-overlays-mobile.png" });
});
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
for (const width of [1440, 1024]) {
test(`batch feature location highlights all matches and reports missing IDs at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await mockInvestigationPresentation(page, []);
const queries: string[] = [];
await page.route("https://workbench-map.invalid/**/ows?**", (route) => {
const filter = new URL(route.request().url()).searchParams.get("cql_filter");
if (!filter) return route.fallback();
queries.push(filter);
const features = [
{ type: "Feature", properties: { id: "P-1" }, geometry: { type: "LineString", coordinates: [[121.4, 31.05], [121.42, 31.06]] } },
{ type: "Feature", properties: { id: "P-2" }, geometry: { type: "LineString", coordinates: [[121.5, 31.12], [121.52, 31.14]] } }
].filter((feature) => filter.includes(`'${feature.properties.id}'`));
return route.fulfill({ json: { type: "FeatureCollection", features } });
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const panel = page.getByTestId("map-dev-panel");
const ids = panel.getByRole("textbox", { name: "要素 ID(可多个)" });
await ids.fill("P-1,P-2\nP-1 missing");
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveText("本次命中 2 个,未找到 1 个:missing");
expect(queries).toEqual(['"id" IN (\'P-1\',\'P-2\',\'missing\')']);
const highlights = () => page.evaluate(() => ["P-1", "P-2"].map((id) =>
window.__waterNetworkMap?.getFeatureState({ source: "pipes", sourceLayer: "pipes", id }).highlighted ?? false
));
await expect.poll(highlights).toEqual([true, true]);
await expect.poll(() => page.evaluate(() => {
const bounds = window.__waterNetworkMap?.getBounds();
return bounds?.contains([121.4, 31.05]) && bounds.contains([121.52, 31.14]);
})).toBe(true);
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((element) => element.clientWidth));
await page.screenshot({ path: `/tmp/map-multi-feature-location-${width}.png` });
await panel.getByRole("button", { name: "清除高亮", exact: true }).click();
await expect.poll(highlights).toEqual([false, false]);
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveCount(0);
await panel.getByRole("button", { name: "缩放到要素", exact: true }).click();
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toContainText("本次命中 2 个");
await expect.poll(highlights).toEqual([false, false]);
await panel.getByRole("button", { name: "仅高亮", exact: true }).click();
await expect.poll(highlights).toEqual([true, true]);
await ids.fill("missing");
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
await expect(panel.getByRole("alert")).toContainText("目标要素不存在");
await expect(panel.getByRole("status").filter({ hasText: "本次命中" })).toHaveText("本次命中 0 个,未找到 1 个:missing");
await expect.poll(highlights).toEqual([true, true]);
await panel.getByRole("button", { name: "清除高亮", exact: true }).click();
await expect.poll(highlights).toEqual([false, false]);
await ids.fill(" ");
await expect(panel.getByRole("button", { name: "定位并高亮", exact: true })).toBeDisabled();
await page.keyboard.press("Escape");
await expect(panel).toBeHidden();
});
}
@@ -0,0 +1,117 @@
import type { Page } from "@playwright/test";
import type { OperationalTask } from "../../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./mock-agent-api";
import { mockWorkbenchContext } from "./mock-workbench-context";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const now = "2026-10-27T08:00:00Z";
export function investigationFixture(index: number, title: string): OperationalTask {
const taskId = `92000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const runId = `93000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
const requestId = `94000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
return {
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title, summary: "仅供浏览器测试的合成调查",
source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst_report" },
trigger: { kind: "manual", occurred_at: now, detail: "synthetic" }, scheduled_for: null,
agent_session_id: "investigation-dev-test", agent_lease_expires_at: now, current_plan_revision: null,
current_report_revision: null, created_at: now, updated_at: now,
active_run: { run_id: runId, task_id: taskId, generation: 1, execution_request_id: requestId,
status: "running", revision: 1, agent_session_id: "investigation-dev-test", lease_expires_at: now,
heartbeat_at: now, started_at: now, finished_at: null, failure_code: null, failure_detail: null,
dispatch_attempts: 1, next_dispatch_at: now, created_at: now, updated_at: now },
latest_plan: null, latest_report: null,
latest_progress: { revision: 1, run_id: runId, execution_generation: 1, published_at: now,
source_session_id: "investigation-dev-test", progress: { summary: "正在核对候选管段", diagram: {
title: "合成调查", mermaid: 'flowchart TD\nA["核查监测"]', node_refs: [{ node_id: "A", label: "核查监测",
status: "running", evidence_refs: [], action_refs: [], map_targets: [{ source_id: "scada", feature_id: "P1" }] }]
} } },
evidence: [], actions: [], access_requests: [], work_order_snapshot: null,
permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false,
can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false,
can_execute_commands: false, can_decide_access: false }
};
}
export async function mockInvestigationPresentation(page: Page, tasks: OperationalTask[]) {
await mockWorkbenchContext(page);
await mockAgentApi(page);
const mutations: string[] = [];
page.on("request", (request) => {
if (request.url().startsWith("https://tjwater-api.invalid/") && ["POST", "PATCH", "DELETE", "PUT"].includes(request.method()))
mutations.push(`${request.method()} ${new URL(request.url()).pathname}`);
});
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript",
body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "", TJWATER_ENABLE_MSW: "false"
})};`
}));
await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: {
items: [{ device_id: "P1", device_type: "pressure", node_id: null, link_id: null, api_query_id: null,
measurement_unit: "m", transmission_mode: "test", transmission_frequency: "15min", reliability: 1,
x: null, y: null, longitude: 121.48, latitude: 31.10 }], total: 1, limit: 1000, offset: 0
} }));
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => {
return route.fulfill({ json: { schema: "workbench-sync-snapshot@1", event_cursor: 10, generated_at: now,
runtime: { availability: "not_configured", source: "none", scenario_availability: "not_configured",
artifact_availability: "not_configured", schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: now, conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] },
tasks: tasks.map((task) => Object.fromEntries(Object.entries(task).filter(([key]) =>
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions"].includes(key)))),
notifications: [] } });
});
await page.route("https://tjwater-api.invalid/api/v1/operational-tasks/**", (route) => {
const task = tasks.find((item) => route.request().url().includes(item.task_id));
if (!task) return route.fulfill({ status: 404 });
if (route.request().url().endsWith("/presentation-cursor")) return route.fulfill({ json: {
task_id: task.task_id, execution_generation: task.execution_generation, active_run: {
run_id: task.active_run!.run_id, generation: task.execution_generation, status: "running"
}, event_cursor: 10
} });
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
return route.fulfill({ json: task });
});
await page.addInitScript(() => {
const fetch = window.fetch.bind(window);
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
Object.assign(window, { emitInvestigationTestEvent: (event: unknown) => {
const data = new TextEncoder().encode(`event: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
for (const stream of streams) stream.enqueue(data);
} });
window.fetch = async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (!url.includes("/api/v1/workbench/events")) return fetch(input, init);
let current: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({ start(controller) { current = controller; streams.add(controller); }, cancel() { streams.delete(current); } });
init?.signal?.addEventListener("abort", () => { if (streams.delete(current)) current.close(); }, { once: true });
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
};
});
let sequence = 10;
return {
mutations,
emit: async (task: OperationalTask, command: unknown) => page.evaluate((event) => {
(window as unknown as { emitInvestigationTestEvent: (event: unknown) => void }).emitInvestigationTestEvent(event);
}, { schema: "workbench-event@1", event_seq: ++sequence,
event_id: `95000000-0000-4000-8000-${String(sequence).padStart(12, "0")}`, project_id: projectId,
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: task.task_id,
aggregate_revision: 1, occurred_at: now, metadata: {
task_id: task.task_id, run_id: task.active_run!.run_id, execution_generation: task.execution_generation, command
} })
};
}
export async function selectInvestigation(page: Page, title: string) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const expand = panel.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click();
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(title, { exact: true }).first().click();
}