Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running turns active while users steer or queue follow-ups and edit pending delivery. Refresh the model catalog before generation and preserve approval mode selection. Integrate scoped investigation messaging/presentation, geographic place selection, current Agent composer styles and supporting workbench contracts. Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and production build. Preserve direct Server/Agent routing and project authentication.
277 lines
19 KiB
TypeScript
277 lines
19 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import type { OperationalTask } from "../../src/features/workbench/api/operational-task-client";
|
|
import { mockAgentApi } from "./support/mock-agent-api";
|
|
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
|
|
|
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
|
const taskId = "92000000-0000-4000-8000-000000000001";
|
|
const runId = "93000000-0000-4000-8000-000000000001";
|
|
const requestId = "94000000-0000-4000-8000-000000000001";
|
|
const now = "2026-10-10T08:00:00Z";
|
|
|
|
test("调查进度替换整图,跟随只展示新指令并在关闭时清理覆盖层", async ({ page }, testInfo) => {
|
|
test.setTimeout(90_000);
|
|
await mockWorkbenchContext(page);
|
|
await mockAgentApi(page);
|
|
await page.route("https://tjwater-api.invalid/api/v1/scada-devices**", (route) => route.fulfill({ json: {
|
|
items: ["P1", "P2"].map((device_id, index) => ({ device_id, device_type: "pressure", node_id: null,
|
|
link_id: null, api_query_id: null, measurement_unit: "MPa", transmission_mode: "test",
|
|
transmission_frequency: "1min", reliability: 1, x: null, y: null,
|
|
longitude: 121.48 + index * 0.001, latitude: 31.1 + index * 0.001 })),
|
|
total: 2, limit: 1000, offset: 0
|
|
} }));
|
|
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"
|
|
})};`
|
|
}));
|
|
// Keep the stream genuinely open: a fulfilled static SSE body is a disconnect.
|
|
await page.addInitScript(() => {
|
|
const animate = Element.prototype.animate;
|
|
const reportAnimations: string[] = [];
|
|
Object.assign(window, { reportAnimations });
|
|
Element.prototype.animate = function (...args) {
|
|
const label = this.getAttribute("aria-label");
|
|
if (label === "当前调查进展" || label === "任务报告") reportAnimations.push(label);
|
|
return animate.apply(this, args);
|
|
};
|
|
const original = window.fetch.bind(window);
|
|
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
Object.assign(window, {
|
|
emitInvestigationEvent: (event: { event_seq: number }) => {
|
|
const bytes = new TextEncoder().encode(`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
|
|
for (const stream of streams) stream.enqueue(bytes);
|
|
}
|
|
});
|
|
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 original(input, init);
|
|
let controller: ReadableStreamDefaultController<Uint8Array>;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
start(value) { controller = value; streams.add(value); },
|
|
cancel() { streams.delete(controller); }
|
|
});
|
|
init?.signal?.addEventListener("abort", () => {
|
|
if (streams.delete(controller)) controller.close();
|
|
}, { once: true });
|
|
return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
|
|
};
|
|
});
|
|
let task: OperationalTask = {
|
|
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
|
|
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: "water_quality_complaint" },
|
|
trigger: { kind: "manual", occurred_at: now, detail: "合成数据" }, scheduled_for: null,
|
|
agent_session_id: "investigation-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-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-test", progress: { summary: "正在核对投诉点位",
|
|
diagram: { title: "实时调查脉络", mermaid: 'flowchart TD\nA["投诉点位核对"] --> B["人工采样"]',
|
|
node_refs: [
|
|
{ node_id: "A", label: "投诉点位核对", status: "running", evidence_refs: [], action_refs: [] },
|
|
{ node_id: "B", label: "人工采样", status: "waiting_human", evidence_refs: [], action_refs: [] }
|
|
] } } },
|
|
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: true, can_execute_work_orders: true, can_decide_commands: false,
|
|
can_execute_commands: false, can_decide_access: false }
|
|
};
|
|
let eventCursor = 10;
|
|
let cursorReads = 0;
|
|
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => {
|
|
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, work_order_snapshot, permissions, ...summary } = task;
|
|
void latest_plan; void latest_report; void latest_progress; void evidence; void actions;
|
|
void access_requests; void work_order_snapshot; void permissions;
|
|
return route.fulfill({ json: {
|
|
schema: "workbench-sync-snapshot@1", event_cursor: eventCursor, 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: [summary], notifications: []
|
|
} });
|
|
});
|
|
await page.route(`https://tjwater-api.invalid/api/v1/operational-tasks/${taskId}**`, (route) => {
|
|
if (route.request().url().endsWith("/presentation-cursor")) {
|
|
cursorReads += 1;
|
|
return route.fulfill({ json: { task_id: taskId, execution_generation: 1,
|
|
active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } });
|
|
}
|
|
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
|
|
return route.fulfill({ json: task });
|
|
});
|
|
const emit = async (command: Record<string, unknown>, seq = ++eventCursor) => {
|
|
await page.evaluate((event) => {
|
|
(window as unknown as { emitInvestigationEvent: (value: unknown) => void }).emitInvestigationEvent(event);
|
|
}, { schema: "workbench-event@1", event_seq: seq,
|
|
event_id: `95000000-0000-4000-8000-${String(seq).padStart(12, "0")}`, project_id: projectId,
|
|
topic: "operational.presentation.requested", aggregate_type: "operational_task", aggregate_id: taskId,
|
|
aggregate_revision: 1, occurred_at: now,
|
|
metadata: { task_id: taskId, run_id: runId, execution_generation: 1, command } });
|
|
};
|
|
await page.goto("/");
|
|
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("合成投诉联动调查", { exact: true }).first().click();
|
|
await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible();
|
|
const diagram = panel.getByLabel("调查脉络", { exact: true });
|
|
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
|
|
const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations);
|
|
expect(await animations()).toEqual([]);
|
|
await diagram.evaluate((element) => { element.setAttribute("data-retained-reader", "true"); });
|
|
task = { ...task, revision: 2, latest_progress: { ...task.latest_progress!, revision: 2,
|
|
progress: { summary: "新增支路复核,等待现场反馈", diagram: { title: "重规划后的调查",
|
|
mermaid: 'flowchart TD\nC["支路复核"] --> D["现场反馈"]', node_refs: [
|
|
{ node_id: "C", label: "支路复核", status: "completed", evidence_refs: [], action_refs: [] },
|
|
{ node_id: "D", label: "现场反馈", status: "waiting_human", evidence_refs: [], action_refs: [] }
|
|
] } } } };
|
|
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
|
await expect(diagram).toContainText("支路复核");
|
|
await expect(diagram).not.toContainText("投诉点位核对");
|
|
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
|
|
await expect(diagram).toHaveAttribute("data-retained-reader", "true");
|
|
await expect.poll(animations).toEqual(["当前调查进展"]);
|
|
|
|
task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1,
|
|
source_session_id: "investigation-test", published_at: now, published_by: "agent", report: {
|
|
title: "合成调查报告", current_conclusion: "合成结论:等待现场核查", conclusion_boundary: "浏览器动画测试",
|
|
evidence_refs: [], recommendations: ["核对现场记录"], pending_items: [], stage: "interim",
|
|
outcome: "concluded", stop_reason: null, data_gaps: [], diagram: null
|
|
} } };
|
|
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
|
const report = panel.getByRole("region", { name: "任务报告", exact: true });
|
|
await expect(report).toContainText("合成结论:等待现场核查");
|
|
await expect.poll(animations).toEqual(["当前调查进展", "任务报告"]);
|
|
await report.getByText("判断说明与建议", { exact: true }).click();
|
|
await report.locator("details").evaluate((element) => element.setAttribute("data-retained-reader", "true"));
|
|
task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2,
|
|
report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } };
|
|
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
|
await expect(report).toContainText("合成结论:现场核查已更新");
|
|
await expect(report.locator("details")).toHaveAttribute("open", "");
|
|
await expect(report.locator("details")).toHaveAttribute("data-retained-reader", "true");
|
|
await expect.poll(animations).toEqual(["当前调查进展", "任务报告", "任务报告"]);
|
|
await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" });
|
|
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3,
|
|
report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } };
|
|
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
|
|
await expect(report).toContainText("合成结论:减少动态效果下更新");
|
|
expect(await animations()).toEqual(["当前调查进展", "任务报告", "任务报告"]);
|
|
await page.emulateMedia({ reducedMotion: "no-preference" });
|
|
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
|
const follow = page.getByRole("checkbox", { name: "跟随所选调查" });
|
|
await expect(follow).toBeChecked();
|
|
const checkboxAlignment = await follow.evaluate((input) => {
|
|
const box = input.getBoundingClientRect();
|
|
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
|
|
return { width: box.width, height: box.height,
|
|
offsetX: mark.x + mark.width / 2 - box.x - box.width / 2,
|
|
offsetY: mark.y + mark.height / 2 - box.y - box.height / 2 };
|
|
});
|
|
expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 });
|
|
await expect.poll(() => cursorReads).toBeGreaterThan(0);
|
|
const dev = page.getByTestId("map-dev-panel");
|
|
const sections = dev.locator("details");
|
|
await expect(sections).toHaveCount(5);
|
|
await expect(sections.filter({ hasText: "调查联动" })).toHaveAttribute("open", "");
|
|
await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", "");
|
|
await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", "");
|
|
await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" });
|
|
const flowSection = sections.filter({ hasText: "水流调试" });
|
|
const flowToggle = flowSection.locator("summary");
|
|
await flowToggle.click();
|
|
await dev.getByLabel("管道 ID", { exact: true }).fill("P-9");
|
|
await flowToggle.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(flowSection).not.toHaveAttribute("open", "");
|
|
await page.keyboard.press("Enter");
|
|
await expect(dev.getByLabel("管道 ID", { exact: true })).toHaveValue("P-9");
|
|
await flowToggle.click();
|
|
await sections.filter({ hasText: "要素定位" }).locator("summary").click();
|
|
await sections.filter({ hasText: "图层样式" }).locator("summary").click();
|
|
await sections.filter({ hasText: "数值标注" }).locator("summary").click();
|
|
await page.setViewportSize({ width: 1024, height: 640 });
|
|
await dev.getByRole("button", { name: "应用标注", exact: true }).scrollIntoViewIfNeeded();
|
|
for (const name of ["数值", "设施 ID"]) {
|
|
const control = dev.getByRole("checkbox", { name, exact: true });
|
|
await expect(control).toHaveCSS("width", "20px");
|
|
expect(await control.evaluate((input) => {
|
|
const box = input.getBoundingClientRect();
|
|
const mark = input.parentElement!.querySelector("svg")!.getBoundingClientRect();
|
|
return [mark.x + mark.width / 2 - box.x - box.width / 2,
|
|
mark.y + mark.height / 2 - box.y - box.height / 2];
|
|
})).toEqual([0, 0]);
|
|
}
|
|
expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
|
await expect(dev.getByRole("button", { name: "重置地图调试效果" })).toBeVisible();
|
|
await testInfo.attach("dev-panel-narrow", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-narrow.png") }), contentType: "image/png" });
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
|
await expect(page.getByLabel("调查实时展示")).toBeVisible();
|
|
await emit({ action: "highlight_features", layer: "scada", feature_ids: ["P1", "P2"], effect: "pulse" });
|
|
const highlightedCount = () => page.evaluate(async () => {
|
|
const map = (window as unknown as { __waterNetworkMap?: {
|
|
getSource: (id: string) => { getData: () => Promise<{ features: unknown[] }> } | undefined
|
|
} }).__waterNetworkMap;
|
|
return (await map?.getSource("investigation-presentation-highlights")?.getData())?.features.length ?? 0;
|
|
});
|
|
await expect.poll(highlightedCount).toBe(2);
|
|
await emit({ action: "clear_highlights" });
|
|
await expect.poll(highlightedCount).toBe(0);
|
|
const chart = { chart_id: "pressure", chart: { title: "合成压力趋势", chart_type: "line",
|
|
x_data: ["08:00", "08:05"], series: [{ name: "压力", data: [0.3, 0.28] }] }, evidence_refs: ["pressure-1"] };
|
|
await emit({ action: "open_chart", ...chart }, 9);
|
|
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
|
|
await emit({ action: "open_chart", ...chart });
|
|
await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible();
|
|
await emit({ action: "close_chart", chart_id: "pressure" });
|
|
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
|
|
const data = { type: "FeatureCollection", features: [
|
|
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.48, 31.10] } },
|
|
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [121.481, 31.101] } }
|
|
] };
|
|
await emit({ action: "set_overlay", overlay_id: "complaints", title: "合成投诉点热力", mode: "heatmap", evidence_id: "spatial-1", data });
|
|
const layerType = () => page.evaluate(() => {
|
|
const map = (window as unknown as { __waterNetworkMap?: { getStyle: () => { layers?: { id: string; type: string }[] } } }).__waterNetworkMap;
|
|
return map?.getStyle().layers?.find((layer) => layer.id === "investigation-presentation-overlay-heatmap")?.type;
|
|
});
|
|
await expect.poll(layerType).toBe("heatmap");
|
|
await expect(page.getByLabel("调查实时展示")).toContainText("合成投诉点热力");
|
|
await page.mouse.move(300, 180);
|
|
await page.mouse.wheel(0, 120);
|
|
await expect(page.getByLabel("调查实时展示")).toContainText("镜头跟随已暂停");
|
|
await page.getByRole("button", { name: "恢复跟随", exact: true }).click();
|
|
await expect(page.getByLabel("调查实时展示")).not.toContainText("镜头跟随已暂停");
|
|
await testInfo.attach("live-investigation-map", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-map.png") }), contentType: "image/png" });
|
|
await page.setViewportSize({ width: 375, height: 720 });
|
|
await expect(page.getByLabel("调查实时展示")).toBeVisible();
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
|
await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" });
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
|
await follow.uncheck();
|
|
await expect(follow).not.toBeChecked();
|
|
await follow.focus();
|
|
await expect(follow).toBeFocused();
|
|
await testInfo.attach("dev-panel-checkbox-off", { body: await dev.screenshot({ path: testInfo.outputPath("dev-panel-checkbox-off.png") }), contentType: "image/png" });
|
|
await expect.poll(() => page.evaluate(() => {
|
|
const map = (window as unknown as { __waterNetworkMap?: { getLayoutProperty: (id: string, key: string) => unknown } }).__waterNetworkMap;
|
|
return map?.getLayoutProperty("investigation-presentation-overlay-heatmap", "visibility");
|
|
})).toBe("none");
|
|
await expect(page.locator('[data-investigation-chart]')).toHaveCount(0);
|
|
});
|