feat(workbench): redesign investigation reports and diagrams

This commit is contained in:
2026-10-10 16:02:47 +08:00
parent 85a9a3ab45
commit ba78a24384
27 changed files with 2380 additions and 187 deletions
+233 -20
View File
@@ -1,6 +1,8 @@
import { expect, test } from "@playwright/test";
import type { OperationalTask, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { writeFile } from "node:fs/promises";
import type { OperationalTask, OperationalTaskNotification, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client";
import { mockAgentApi } from "./support/mock-agent-api";
import { expectThemeColor } from "./support/theme-colors";
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const taskId = "82000000-0000-4000-8000-000000000001";
@@ -8,7 +10,7 @@ const actionId = "83000000-0000-4000-8000-000000000001";
const accessId = "84000000-0000-4000-8000-000000000001";
const now = "2026-09-24T08:00:00Z";
test("动态调查显示选定路线与嵌套并行,并完成授权和工单草稿确认", async ({ page }, testInfo) => {
test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常结束", async ({ page }, testInfo) => {
test.setTimeout(90_000);
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "text/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
@@ -34,7 +36,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
planNode("split", "同步核对现场与数据", "parallel_split", "completed"),
planNode("field", "现场采样", "activity", "waiting"),
planNode("data-split", "多源数据核对", "parallel_split", "completed"),
planNode("upstream", "上游监测", "activity", "completed"),
{ ...planNode("upstream", "上游监测", "activity", "completed"), evidence_refs: ["monitor-1"] },
planNode("network", "管网运行", "activity", "completed"),
planNode("data-join", "数据证据汇合", "parallel_join", "completed"),
planNode("join", "现场与数据汇合", "parallel_join", "waiting"),
@@ -64,7 +66,14 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
agent_session_id: "dynamic-flow-session", agent_lease_expires_at: null, current_plan_revision: 1, current_report_revision: null,
created_at: now, updated_at: now,
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session", published_at: now, published_by: "agent", plan },
latest_report: null, evidence: [],
latest_report: { revision: 1, execution_generation: 1, source_session_id: "dynamic-flow-session",
published_at: now, published_by: "agent", report: {
title: "水质投诉调查报告", current_conclusion: "上游记录未见同期异常,需现场核查用户端。", conclusion_boundary: "仅覆盖已有上游监测记录。",
evidence_refs: ["monitor-1"], recommendations: ["现场核查"], pending_items: [], stage: "interim", outcome: "concluded", stop_reason: null, data_gaps: [],
diagram: { title: "调查脉络", mermaid: 'flowchart TD\nA["水质投诉"] --> B{"紧急风险?"}\nB -->|有| C["现场核查"]\nB -->|无| D["常规核查"]\nA --> E["供水路径"]\nsubgraph F["多源证据核对"]\nE --> G["上游监测"]\nE --> H["管网运行"]\nE --> I["相关投诉"]\nend\nG --> J["汇总判断"]\nH --> J\nI --> J\nC --> J\nD --> J\nJ --> K["动作草稿"]\nK --> L{"人工审核"}\nL -->|批准| M["执行与回执"]\nL -->|修改| K',
node_refs: [{ node_id: "G", label: "上游监测", evidence_refs: ["monitor-1"], action_refs: [] },
{ node_id: "K", label: "动作草稿", evidence_refs: [], action_refs: [actionId] }] }
} }, evidence: [{ evidence_id: "monitor-1", kind: "system_data", summary: "上游监测数据质量已核对", source_label: "SCADA 水质监测", source_ref: "WQ-01", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
access_requests: [{ access_request_id: accessId, task_id: taskId, node_id: "data-split", scopes: ["scada_devices", "scada_readings"],
reason: "需要核对上游设备和读数", status: "pending", approved_scopes: [], version: 1,
requested_by: "agent", requested_at: now, decided_by: null, decided_at: null, comment: null }],
@@ -77,6 +86,8 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
can_decide_work_orders: true, can_execute_work_orders: true, can_decide_commands: false, can_execute_commands: false, can_decide_access: true },
work_order_snapshot: null
};
const firstReport = task.latest_report!;
let notifications: OperationalTaskNotification[] = [];
const summary = (item: OperationalTask): OperationalTaskSummary => {
const { latest_plan, latest_report, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
void latest_plan; void latest_report; void evidence; void actions; void access_requests; void permissions; void work_order_snapshot;
@@ -87,16 +98,24 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
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(task)], notifications: [], generated_at: now
tasks: [summary(task)], notifications, generated_at: now
} }));
await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": keepalive\n\n" }));
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }));
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => {
if (route.request().method() === "POST") {
expect(route.request().postDataJSON().expected_version).toBe(1);
notifications = notifications.map((item) => ({ ...item, version: 2, status: "read", read_at: now, read_by: "reviewer" }));
return route.fulfill({ json: notifications[0] });
}
return route.fulfill({ json: { items: notifications, limit: 100, offset: 0, next_offset: null } });
});
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const path = new URL(route.request().url()).pathname;
const method = route.request().method();
if (path.endsWith("/operational-tasks/schedules")) return route.fulfill({ json: { items: [] } });
if (method === "GET" && path.endsWith("/operational-tasks")) return route.fulfill({ json: { items: [summary(task)], limit: 50, offset: 0, next_offset: null } });
if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}`)) return route.fulfill({ json: task });
if (method === "GET" && path.endsWith(`/operational-tasks/${taskId}/reports`)) return route.fulfill({ json: { items: [task.latest_report, firstReport].filter((item, index, items) => items.findIndex((other) => other?.revision === item?.revision) === index), next_offset: null } });
if (method === "POST" && path.endsWith(`/access-requests/${accessId}/decision`)) {
const body = route.request().postDataJSON();
expect(body.approved_scopes).toEqual(["scada_devices"]);
@@ -107,6 +126,7 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
if (method === "PATCH" && path.endsWith(`/actions/${actionId}/draft`)) {
const body = route.request().postDataJSON();
expect(body.payload.title).toBe("现场采样修订稿");
expect(body.payload.work_order_type).toBe("general");
const action = { ...task.actions[0], version: 2, payload: body.payload, edited_by: "reviewer", edited_at: now };
task = { ...task, actions: [action] };
return route.fulfill({ json: action });
@@ -129,35 +149,185 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
const panel = page.getByRole("region", { name: "工况任务", exact: true });
const expand = panel.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click();
const desktopColumns = panel.locator(".scheduled-feed-layout-expanded > div");
const captureColumns = async () => {
const first = await desktopColumns.nth(0).boundingBox();
const second = await desktopColumns.nth(1).boundingBox();
expect(first).not.toBeNull();
expect(second).not.toBeNull();
return [first!, second!].map((box) => ({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }));
};
await expect(panel).toHaveCSS("width", "880px");
const scheduledColumns = await captureColumns();
await testInfo.attach("scheduled-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("scheduled-tab-alignment.png") }), contentType: "image/png" });
await panel.getByRole("button", { name: /预约任务/ }).click();
await expect.poll(captureColumns).toEqual(scheduledColumns);
const appointmentColumns = await captureColumns();
await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText("水质投诉动态调查", { exact: true }).first().click();
await expect(panel.getByText(/路线选择:/).first().locator("..")).toContainText("有紧急风险");
await expect(panel.getByLabel("并行处理 2 路")).toHaveCount(2);
await expect(panel.locator('[data-execution-node="data-join"]')).toHaveCount(1);
await expect(panel.getByText("其他规划路线 1 项")).toBeVisible();
await expect(panel.getByRole("heading", { name: "当前结果" })).toBeVisible();
await expect.poll(captureColumns).toEqual(scheduledColumns);
await writeFile(testInfo.outputPath("tab-column-geometry.json"), JSON.stringify({
viewport: page.viewportSize(), scheduled: scheduledColumns, appointment: appointmentColumns,
investigation: await captureColumns()
}, null, 2));
const reportBounds = await desktopColumns.nth(0).boundingBox();
const itemBounds = await desktopColumns.nth(1).boundingBox();
expect(reportBounds).not.toBeNull();
expect(itemBounds).not.toBeNull();
expect(reportBounds!.x + reportBounds!.width).toBeLessThanOrEqual(itemBounds!.x);
expect(reportBounds!.width).toBeGreaterThan(itemBounds!.width);
await testInfo.attach("investigation-tab-alignment", { body: await panel.screenshot({ path: testInfo.outputPath("investigation-tab-alignment.png") }), contentType: "image/png" });
const diagram = panel.getByLabel("调查脉络", { exact: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg[id]")).toHaveCount(1);
await expect(diagram).toContainText("常规核查");
const captureDiagramControls = async (surface: typeof diagram) => {
return surface.locator("button[title]").evaluateAll((buttons) => buttons.map((button) => {
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
const icon = button.querySelector("svg");
const iconRect = icon?.getBoundingClientRect();
return { title: button.getAttribute("title"), x: rect.x, y: rect.y, width: rect.width, height: rect.height,
radius: style.borderRadius, iconWidth: iconRect?.width, iconHeight: iconRect?.height };
}));
};
const inlineControls = await captureDiagramControls(diagram);
const assertControlGeometry = (controls: typeof inlineControls) => {
expect(controls).toHaveLength(4);
for (const control of controls) {
expect({ width: control.width, height: control.height, radius: control.radius,
iconWidth: control.iconWidth, iconHeight: control.iconHeight }).toEqual({
width: 32, height: 32, radius: "8px", iconWidth: 16, iconHeight: 16
});
}
};
assertControlGeometry(inlineControls);
const zoomIn = diagram.getByRole("button", { name: "Zoom in", exact: true });
const zoomOut = diagram.getByRole("button", { name: "Zoom out", exact: true });
const resetZoom = diagram.getByRole("button", { name: "Reset zoom and pan", exact: true });
const diagramCanvas = diagram.locator('[role="application"]');
await zoomIn.hover();
await expectThemeColor(zoomIn, "background-color", "--action-soft");
await expectThemeColor(zoomIn, "color", "--action-blue-hover");
const zoomBox = (await zoomIn.boundingBox())!;
// The compact icon keeps a 40px hit area outside its 32px visible frame.
await page.mouse.move(zoomBox.x - 2, zoomBox.y + zoomBox.height / 2);
await page.mouse.down();
await expectThemeColor(zoomIn, "background-color", "--action-soft-pressed");
await page.mouse.up();
await expect(diagramCanvas).toHaveCSS("transform", "matrix(1.1, 0, 0, 1.1, 0, 0)");
await resetZoom.click();
await expect(diagramCanvas).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 0)");
for (let index = 0; index < 8 && await zoomOut.isEnabled(); index++) await zoomOut.click();
await expect(zoomOut).toBeDisabled();
await expect(zoomOut).toHaveCSS("opacity", "0.45");
await resetZoom.click();
await expect(zoomOut).toBeEnabled();
await zoomIn.focus();
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(zoomIn).toBeFocused();
await expect(zoomIn).toHaveCSS("outline-width", "2px");
await expectThemeColor(zoomIn, "outline-color", "--action-focus-ring");
await zoomIn.locator("..").screenshot({ path: testInfo.outputPath("diagram-controls-keyboard-focus.png") });
await testInfo.attach("styled-investigation-diagram", { body: await diagram.screenshot({ path: testInfo.outputPath("styled-investigation-diagram.png") }), contentType: "image/png" });
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
const fullscreen = page.locator('body > div[role="button"]').filter({ has: page.getByRole("button", { name: "退出全屏", exact: true }) });
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
const fullscreenControls = await captureDiagramControls(fullscreen);
assertControlGeometry(fullscreenControls);
await writeFile(testInfo.outputPath("diagram-control-geometry.json"), JSON.stringify({ viewport: page.viewportSize(), inline: inlineControls, fullscreen: fullscreenControls }, null, 2));
await testInfo.attach("styled-investigation-fullscreen", { body: await page.screenshot({ path: testInfo.outputPath("styled-investigation-fullscreen.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
await expect(fullscreen).toHaveCount(0);
await diagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await expect(fullscreen).toBeVisible();
await page.keyboard.press("Escape");
await expect(fullscreen).toHaveCount(0);
await diagram.locator(".node").filter({ hasText: "上游监测" }).click({ timeout: 5000 });
await expect(panel.getByLabel("节点详情 上游监测")).toBeVisible();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).click();
await diagram.getByRole("button", { name: "上游监测 · 1 条依据" }).focus();
await page.keyboard.press("Enter");
await expect(panel.getByLabel("节点详情 上游监测")).toContainText("上游监测数据质量已核对");
await testInfo.attach("dynamic-investigation-desktop", {
body: await panel.screenshot({ path: testInfo.outputPath("dynamic-investigation-desktop.png") }), contentType: "image/png"
});
await panel.getByRole("button", { name: "Agent 协作" }).click();
const agentPanel = page.getByRole("complementary", { name: "Agent 命令面板" });
const review = agentPanel.getByRole("region", { name: "当前任务审核" });
await expect(review).toContainText("需要核对上游设备和读数");
const review = agentPanel.getByRole("region", { name: "任务协作状态" });
await expect(review).toContainText("现场采样需要派单");
await expect(review.getByRole("heading", { name: "关联预约工单" })).toBeVisible();
await expect(review).toContainText("调查任务只保留创建关联");
await expect(review).toContainText("预约工单");
await expect(review.getByText("数据访问申请", { exact: true })).toHaveCount(0);
await testInfo.attach("linked-appointment-review-desktop", {
body: await review.screenshot({ path: testInfo.outputPath("linked-appointment-review-desktop.png") }), contentType: "image/png"
});
await page.setViewportSize({ width: 375, height: 720 });
const mobileAgentSheet = page.getByRole("region", { name: "Agent 工作台抽屉" });
if (!await mobileAgentSheet.isVisible()) await page.getByRole("button", { name: "打开 Agent 面板" }).click();
await expect(mobileAgentSheet.getByRole("region", { name: "当前任务审核" })).toBeVisible();
await expect(mobileAgentSheet.getByRole("region", { name: "任务协作状态" })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.setViewportSize({ width: 1280, height: 720 });
await expect(review).toBeVisible();
await review.getByLabel("SCADA 监测读数").uncheck();
await review.getByRole("button", { name: "批准所选范围" }).click();
await expect(panel.getByText("已授权:SCADA 设备")).toBeVisible();
await expect(panel.getByLabel("数据访问审核")).toHaveCount(0);
await review.getByRole("button", { name: "编辑草稿" }).click();
await expect(review.getByRole("combobox", { name: "工单类型" })).toHaveCount(0);
await expect(review).toContainText("工单归属");
await expect(review).toContainText("预约任务");
await review.getByRole("textbox", { name: "工单标题" }).fill("现场采样修订稿");
await review.getByRole("button", { name: "保存草稿" }).click();
await expect(panel.getByText("现场采样修订稿").first()).toBeVisible();
await review.getByRole("button", { name: "确认并下发工单" }).click();
await expect(panel.getByRole("region", { name: "执行回执" })).toContainText("WO-20260924-1");
await review.getByRole("button", { name: "确认并创建预约工单" }).click();
await expect(review.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
task = { ...task, status: "completed", lifecycle_status: "completed", revision: 8,
access_requests: [], current_report_revision: 2,
evidence: [...task.evidence, { evidence_id: "sample-query", kind: "synthetic", summary: "已查询投诉时段,采样记录为空", source_label: "外围采样记录", source_ref: "sample-query", observed_at: now, recorded_at: now, recorded_by: "agent", has_snapshot: false }],
latest_plan: { ...task.latest_plan!, plan: { ...plan, nodes: plan.nodes.map((node) => ({
...node, status: node.status === "waiting" ? "skipped" as const : node.status,
detail: node.status === "waiting" ? "关键数据不足,本轮未进入" : node.detail
})) } },
latest_report: { revision: 2, execution_generation: 1, source_session_id: "dynamic-flow-session",
published_at: now, published_by: "agent", report: {
title: "水质投诉调查报告", current_conclusion: "无法确定投诉原因", conclusion_boundary: "仅完成上游与管网数据核对",
evidence_refs: ["monitor-1", "sample-query"], recommendations: [], pending_items: [], stage: "final", outcome: "insufficient_data",
stop_reason: "缺少投诉时段的末梢采样结果", data_gaps: [{ gap_id: "sample-gap", node_id: "field",
missing_data: "末梢采样结果", scope: "投诉小区,2026-09-24 08:00 至 09:00", impact: "无法区分管网与用户端异常",
required_data: "补齐该时段末梢采样结果", evidence_refs: ["sample-query"], blocking: true }]
} }
};
notifications = [{ notification_id: "87000000-0000-4000-8000-000000000001", task_id: taskId, kind: "report_updated",
title: "有新报告", description: "末梢采样缺失", lifecycle_status: "active", subject_type: "task", subject_id: taskId,
report_reference: { execution_generation: 1, revision: 2 }, resolved_at: null, resolution_reason: null,
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null }];
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(panel.getByRole("button", { name: "查看新报告" })).toBeVisible();
await expect(panel.getByText("新报告", { exact: true })).toBeVisible();
expect(notifications[0].status).toBe("unread");
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "查看新报告" }).click();
await expect.poll(() => notifications[0].status).toBe("read");
await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
await expect(panel.getByLabel("数据缺口")).toContainText("无法区分管网与用户端异常");
await expect(panel.getByText("已结束 · 证据不足", { exact: true }).first()).toHaveAttribute("data-status-tone", "warning");
await expect(panel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
await expect(page.getByRole("button", { name: /展开 Agent 助手面板/ })).toBeVisible();
await expect(panel).toHaveCSS("width", "880px");
await panel.getByRole("heading", { name: "当前结果", exact: true }).scrollIntoViewIfNeeded();
await testInfo.attach("insufficient-report-desktop", { body: await panel.screenshot({
path: testInfo.outputPath("insufficient-report-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
const timeline = panel.getByLabel("任务时间线记录");
await expect(timeline).toContainText("采样工单已创建");
await testInfo.attach("investigation-timeline-desktop", { body: await timeline.screenshot({
path: testInfo.outputPath("investigation-timeline-desktop.png") }), contentType: "image/png" });
await panel.getByText("任务时间线", { exact: true }).click();
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
@@ -171,11 +341,54 @@ test("动态调查显示选定路线与嵌套并行,并完成授权和工单
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
if (await mobileExpand.isVisible()) await mobileExpand.click();
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
await expect(mobilePanel.getByLabel("并行处理 2 路")).toHaveCount(2);
await mobilePanel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(mobilePanel.getByLabel("调查脉络", { exact: true }).locator("svg[id]")).toHaveCount(1);
const mobileDiagram = mobilePanel.getByLabel("调查脉络", { exact: true });
assertControlGeometry(await captureDiagramControls(mobileDiagram));
if (width === 320) {
await mobileDiagram.getByRole("button", { name: "全屏查看", exact: true }).click();
await expect(fullscreen.locator("svg[id]")).toHaveCount(1);
assertControlGeometry(await captureDiagramControls(fullscreen));
await testInfo.attach("diagram-fullscreen-320", { body: await page.screenshot({ path: testInfo.outputPath("diagram-fullscreen-320.png") }), contentType: "image/png" });
await fullscreen.getByRole("button", { name: "退出全屏", exact: true }).click();
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await mobilePanel.locator('[data-execution-node="split"]').scrollIntoViewIfNeeded();
await mobilePanel.getByLabel("任务报告", { exact: true }).scrollIntoViewIfNeeded();
await expect(mobilePanel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
await testInfo.attach(`dynamic-investigation-${width}`, {
body: await sheet.screenshot({ path: testInfo.outputPath(`dynamic-investigation-${width}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
await panel.getByText("报告版本", { exact: true }).click();
await expect(panel.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
await panel.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
await page.route("**/api/v1/agent/chat/stream", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
expect(route.request().postDataJSON().task_context).toEqual({ report_revision: 1, evidence_id: "monitor-1" });
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream", "X-Vercel-AI-UI-Message-Stream": "v1" },
body: [{ type: "start", messageId: "selected-report-answer" },
{ type: "data-task_context", data: { task_id: taskId, execution_generation: 1, report_revision: 1 } },
{ type: "text-start", id: "answer" }, { type: "text-delta", id: "answer", delta: "这条依据仅覆盖上游监测范围。" },
{ type: "text-end", id: "answer" }, { type: "finish", finishReason: "stop" }].map((part) => `data: ${JSON.stringify(part)}\n\n`).join("") });
});
await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
await expect(agentPanel).toContainText("依据:第 1 轮 · 报告第 1 版");
task = { ...task, revision: 9, execution_generation: 2, current_report_revision: 3,
latest_report: { ...firstReport, revision: 3, execution_generation: 2, report: {
...firstReport.report, stage: "final", current_conclusion: "合成报告:仅用于检查图示失败时的阅读体验。",
diagram: { title: "调查脉络", mermaid: "flowchart TD\nA[未闭合", node_refs: [] }
} } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await page.getByRole("button", { name: "折叠 Agent 面板", exact: true }).click();
const reopen = panel.getByRole("button", { name: "展开工况任务" });
if (await reopen.isVisible()) await reopen.click();
const viewNew = panel.getByRole("button", { name: "查看新报告" });
if (await viewNew.isVisible()) await viewNew.click();
await panel.getByLabel("调查脉络", { exact: true }).scrollIntoViewIfNeeded();
await expect(panel.getByText("图示暂不可用,仍可查看结论、证据和待审核动作。", { exact: true })).toBeVisible();
await expect(panel.getByLabel("任务报告", { exact: true })
.getByText("合成报告:仅用于检查图示失败时的阅读体验。", { exact: true })).toBeAttached();
});
@@ -0,0 +1,125 @@
import { expect, test } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import { mockAgentApi } from "./support/mock-agent-api";
test("调查图样式对照保留中文标题、分工和真实 SVG 关系", async ({ page }, testInfo) => {
await page.route("**/api/v1/**", (route) => route.fulfill({ json: { items: [] } }));
await mockAgentApi(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.setViewportSize({ width: 1536, height: 1250 });
await page.setContent(`<html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body>
<main id="style-comparison" style="padding:32px;background:#EFF2F7;min-height:100vh;color:#26334D;font-family:'Noto Sans SC Variable',sans-serif">
<h1 style="font-size:22px;font-weight:600;margin-bottom:8px">调查脉络 · 三种视觉表达</h1>
<p style="font-size:13px;color:#68778E;margin-bottom:24px">使用截图中的节点与关系制作的样式设计示例</p>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px">
<section><h2 style="font-size:16px;font-weight:600">A · 工程流程</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">白底、分工分组、明确的节点和连线层次</p><div id="engineering"></div></section>
<section><h2 style="font-size:16px;font-weight:600">B · 墨色报告</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">黑白、直角、虚实线区分分工,适合打印</p><div id="ink"></div></section>
<section><h2 style="font-size:16px;font-weight:600">C · 手绘白板</h2><p style="font-size:12px;color:#68778E;margin:8px 0 16px">纸色、手绘线条,适合讲解设计思路</p><div id="sketch"></div></section>
</div>
</main><style>
#style-comparison [data-streamdown="mermaid"], #style-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:transparent;}
#style-comparison [data-streamdown="mermaid"] [role="img"] {width:100%;}
#style-comparison .font-mono {display:none;}
</style></body></html>`);
const renderCandidate = async (candidate: string) => page.evaluate(async (selected) => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const titles = ["读取后端任务上下文与授权范围", "查询授权范围内的现有数据", "核实事件位置与生产资产映射", "现场核验爆管情况与影响范围", "停水隔离", "抢修作业", "复水与回访"];
const edges = ["已读上下文后查询数据", "依据查询结果核实资产映射", "映射缺失需现场核验", "确认爆管后停水", "停水后抢修", "抢修后复水"];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: titles.map((title, index) => ({ id: `node${index}`, title, owner: index < 3 ? "agent" : "human", kind: "activity", evidence_refs: [], action_refs: [] })),
edges: edges.map((condition, index) => ({ from: `node${index}`, to: `node${index + 1}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, titles.join(""));
let source = styleInvestigationDiagram(diagram);
let config = investigationDiagramConfig;
if (selected === "ink") {
source += "\nclassDef tjAgent fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px;\nclassDef tjHuman fill:#FFFFFF,stroke:#4B5262,color:#29303E,stroke-width:1.3px,stroke-dasharray:5 3;";
config = { ...config, themeVariables: { ...config.themeVariables, clusterBkg: "#FFFFFF", clusterBorder: "#C5CBD5", lineColor: "#606A7B" },
themeCSS: config.themeCSS + " .node rect,.cluster rect {rx:0;ry:0;} .nodeLabel{font-weight:400;} .cluster-label .nodeLabel{background:#FFFFFF;}" };
}
if (selected === "sketch") {
source += "\nclassDef tjAgent fill:#EDF4E9,stroke:#788A6D,color:#3B5135,stroke-width:1.3px;\nclassDef tjHuman fill:#FFF4DC,stroke:#B39964,color:#6B572B,stroke-width:1.3px;";
config = { ...config, look: "handDrawn", flowchart: { ...config.flowchart, curve: "basis", rankSpacing: 44 },
themeVariables: { ...config.themeVariables, fontSize: "16px", clusterBkg: "#FFFCF4", clusterBorder: "#CAC3AF", lineColor: "#8E8979" },
themeCSS: config.themeCSS + " .cluster-label .nodeLabel{background:#FFFCF4;}" };
}
createRoot(document.getElementById(selected)).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + source + "\n```"
}));
}, candidate);
for (const candidate of ["engineering", "ink", "sketch"]) {
await renderCandidate(candidate);
const surface = page.locator(`#${candidate}`);
await surface.scrollIntoViewIfNeeded();
await expect(surface.locator("svg[id]")).toHaveCount(1);
await expect(surface).toContainText("读取后端任务上下文与授权范围");
await expect(surface).toContainText("人工 · 现场协作");
await writeFile(testInfo.outputPath(`${candidate}.svg`), await surface.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await surface.screenshot({ path: testInfo.outputPath(`${candidate}.png`) });
}
await page.locator("#style-comparison").screenshot({ path: testInfo.outputPath("investigation-style-comparison.png") });
await page.setViewportSize({ width: 1740, height: 1450 });
await page.evaluate(async () => {
const reactModule = await import("/node_modules/.vite/deps/react.js");
const React = reactModule.default ?? reactModule;
const clientModule = await import("/node_modules/.vite/deps/react-dom_client.js");
const { createRoot } = clientModule.default ?? clientModule;
const { MessageResponse } = await import("/src/shared/ai-elements/message.tsx");
const { investigationDiagramConfig, investigationDiagramPlugins, INVESTIGATION_DIAGRAM_FONT, styleInvestigationDiagram } = await import("/src/features/workbench/utils/investigation-diagram-style.ts");
const { investigationDiagram } = await import("/src/features/workbench/utils/investigation-presentation.ts");
const labels = ["工单接入与信息登记", "上游监测点数据质量核查", "上游演示指标阈值比对", "相邻片区同类投诉排查", "投诉分布范围确认", "阶段性判断", "二次供水水箱清洗记录核验", "现场采样", "水箱清洗处置", "复测", "居民回访"];
const links = [[0,1,"并行核查"],[0,2,"并行核查"],[0,3,"并行核查"],[0,4,"并行核查"],
[1,5,"数据可用性结论"],[2,5,"上游水质结论"],[3,5,"范围排除依据"],[4,5,"投诉分布依据"],
[5,7,"条件分支:需现场核验"],[5,6,"条件分支:疑似二次供水问题 → 核验清洗记录"],
[6,8,"条件分支:清洗记录缺失或异常 → 清洗处置"],[7,9,"条件分支:采样支持处置后复测"],
[8,9,"条件分支:处置后复测"],[9,10,"条件分支:复测结果反馈回访"]];
const diagram = investigationDiagram({ execution_generation: 1, latest_report: null, latest_plan: { execution_generation: 1, plan: {
nodes: labels.map((title, index) => ({ id: `parallel${index}`, title, owner: index < 6 ? "agent" : "human", kind: "activity",
evidence_refs: index < 7 ? [`design-example-${index}`] : [], action_refs: [] })),
edges: links.map(([from,to,condition]) => ({ from: `parallel${from}`, to: `parallel${to}`, condition }))
} } }, null);
await document.fonts.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, labels.join(""));
document.getElementById("style-comparison").remove();
const container = document.createElement("main"); container.id = "parallel-comparison";
container.style.cssText = "padding:32px;background:#FFFFFF;font-family:'Noto Sans SC Variable',sans-serif";
document.body.append(container);
createRoot(container).render(React.createElement(MessageResponse, {
plugins: investigationDiagramPlugins,
mermaid: { config: investigationDiagramConfig }, controls: { mermaid: { panZoom: false, fullscreen: false, download: false, copy: false } },
children: "```mermaid\n" + styleInvestigationDiagram(diagram) + "\n```"
}));
const style = document.createElement("style");
style.textContent = '#parallel-comparison [data-streamdown="mermaid"],#parallel-comparison [data-streamdown="mermaid"] > div {max-height:none!important;background:#FFFFFF} #parallel-comparison [data-streamdown="mermaid"] [role="img"]{width:100%} #parallel-comparison .font-mono{display:none}';
document.head.append(style);
});
const parallel = page.locator("#parallel-comparison");
await parallel.scrollIntoViewIfNeeded();
await expect(parallel.locator("svg[id]")).toHaveCount(1);
const titleGap = await parallel.evaluate((element) => {
const title = [...element.querySelectorAll(".cluster-label")].find((item) => item.textContent?.includes("Agent"));
const firstNode = [...element.querySelectorAll(".node")].find((item) => item.textContent?.includes("工单接入"));
return firstNode!.getBoundingClientRect().top - title!.getBoundingClientRect().bottom;
});
const captionOverlaps = await parallel.evaluate((element) => {
const labels = [...element.querySelectorAll(".edgeLabel")].map((label) => label.getBoundingClientRect());
return [...element.querySelectorAll(".cluster-label")].flatMap((caption) => {
const box = caption.getBoundingClientRect();
return labels.some((label) => box.left < label.right && box.right > label.left && box.top < label.bottom && box.bottom > label.top)
? [caption.textContent]
: [];
});
});
await writeFile(testInfo.outputPath("parallel-diagram.svg"), await parallel.locator("svg[id]").evaluate((svg) => svg.outerHTML));
await parallel.screenshot({ path: testInfo.outputPath("parallel-diagram.png") });
await writeFile(testInfo.outputPath("parallel-diagram-spacing.json"), JSON.stringify({ viewport: page.viewportSize(), titleGap }, null, 2));
expect(titleGap).toBeGreaterThanOrEqual(18);
expect(captionOverlaps).toEqual([]);
});