Files
next-tjwater-frontend/tests/browser/dynamic-investigation-flow.e2e.ts
T

440 lines
32 KiB
TypeScript

import { expect, test } from "@playwright/test";
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";
const actionId = "83000000-0000-4000-8000-000000000001";
const accessId = "84000000-0000-4000-8000-000000000001";
const now = "2026-09-24T08:00:00Z";
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({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_MAP_URL: "http://map.test/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
})}` }));
await page.route("http://server.test/api/v1/projects**", (route) => route.fulfill({ json: {
items: [{ project_id: projectId, gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0
} }));
await page.route("http://server.test/api/v1/scada-devices**", (route) => route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } }));
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
const planNode = (id: string, title: string, kind: "activity" | "decision" | "parallel_split" | "parallel_join" | "gate", status: "completed" | "waiting" | "skipped") => ({
id, title, owner: "agent" as const, status, detail: `${title}的执行记录`, kind,
evidence_refs: [], action_refs: [], selected_edge_id: null, gate_ref: null, revisits_node_id: null
});
const plan = {
schema: "execution-plan@2" as const, title: "水质投诉动态调查", reason: "按投诉位置和风险规划",
nodes: [
{ ...planNode("risk", "判断紧急风险", "decision", "completed"), selected_edge_id: "urgent-route" },
planNode("urgent", "立即现场核查", "activity", "completed"),
planNode("routine", "常规现场核查", "activity", "skipped"),
planNode("split", "同步核对现场与数据", "parallel_split", "completed"),
planNode("field", "现场采样", "activity", "waiting"),
planNode("data-split", "多源数据核对", "parallel_split", "completed"),
{ ...planNode("upstream", "上游监测", "activity", "completed"), evidence_refs: ["monitor-1"] },
planNode("network", "管网运行", "activity", "completed"),
planNode("data-join", "数据证据汇合", "parallel_join", "completed"),
planNode("join", "现场与数据汇合", "parallel_join", "waiting"),
{ ...planNode("gate", "确认采样工单", "gate", "waiting"), gate_ref: actionId }
],
edges: [
{ id: "urgent-route", from: "risk", to: "urgent", condition: "有紧急风险" },
{ id: "routine-route", from: "risk", to: "routine", condition: "无紧急风险" },
{ id: "e1", from: "urgent", to: "split", condition: null },
{ id: "e2", from: "split", to: "field", condition: "现场" },
{ id: "e3", from: "split", to: "data-split", condition: "Agent 数据" },
{ id: "e4", from: "field", to: "join", condition: null },
{ id: "e5", from: "data-split", to: "upstream", condition: "上游" },
{ id: "e6", from: "data-split", to: "network", condition: "管网" },
{ id: "e7", from: "upstream", to: "data-join", condition: null },
{ id: "e8", from: "network", to: "data-join", condition: null },
{ id: "e9", from: "data-join", to: "join", condition: null },
{ id: "e10", from: "join", to: "gate", condition: null }
]
};
let task: OperationalTask = {
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent",
revision: 2, execution_generation: 1, execution_request_id: "85000000-0000-4000-8000-000000000001",
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: "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: { 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 }],
actions: [{ action_id: actionId, task_id: taskId, version: 1, kind: "create_work_order", node_id: "gate", reason: "现场采样需要派单",
payload: { title: "旧采样草稿", description: "现场采样", work_order_type: "water_quality_complaint", priority: "urgent", location: "演示小区" },
evidence_refs: [], required_approval_permission: "operational_action.work_order.approve", required_execution_permission: "operational_action.work_order.execute",
approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: now,
decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: 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: 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;
return value;
};
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
schema: "workbench-sync-snapshot@1", event_cursor: 0,
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
} }));
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) => {
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"]);
const access = { ...task.access_requests[0], version: 2, status: "approved" as const, approved_scopes: body.approved_scopes, decided_by: "reviewer", decided_at: now };
task = { ...task, access_requests: [access] };
return route.fulfill({ json: access });
}
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 });
}
if (method === "POST" && path.endsWith(`/actions/${actionId}/confirm-dispatch`)) {
const body = route.request().postDataJSON();
expect(body.expected_version).toBe(2);
const action = { ...task.actions[0], version: 4, approval_status: "approved" as const, execution_status: "succeeded" as const,
decided_by: "reviewer", decided_at: now, executed_by: "reviewer", executed_at: now,
execution_request_id: "86000000-0000-4000-8000-000000000001",
receipt: { adapter: "work-order", receipt_id: "WO-20260924-1", submitted_at: now, completed_at: now, external_status: "completed", detail: "采样工单已创建" } };
task = { ...task, actions: [action] };
return route.fulfill({ json: action });
}
return route.fulfill({ status: 404, json: {} });
});
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();
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.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
});
}
};
const assertInlineControlRail = async (surface: typeof diagram) => {
const mermaidBlock = surface.locator('[data-streamdown="mermaid-block"]');
const graph = surface.locator('[data-streamdown="mermaid"]');
const fullscreenButton = surface.getByRole("button", { name: "全屏查看", exact: true });
const zoomInButton = surface.getByRole("button", { name: "Zoom in", exact: true });
const fullscreenGroup = surface.locator('[data-streamdown="mermaid-block-actions"]');
const zoomGroup = zoomInButton.locator("..");
const [blockBox, graphBox, fullscreenBox, zoomInBox, fullscreenGroupBox, zoomGroupBox] = await Promise.all([
mermaidBlock.boundingBox(), graph.boundingBox(), fullscreenButton.boundingBox(), zoomInButton.boundingBox(),
fullscreenGroup.boundingBox(), zoomGroup.boundingBox()
]);
expect(blockBox).not.toBeNull();
expect(graphBox).not.toBeNull();
expect(fullscreenBox).not.toBeNull();
expect(zoomInBox).not.toBeNull();
expect(fullscreenGroupBox).not.toBeNull();
expect(zoomGroupBox).not.toBeNull();
expect(Math.round(graphBox!.y - blockBox!.y)).toBeLessThanOrEqual(10);
expect(Math.round(fullscreenBox!.x)).toBe(Math.round(zoomInBox!.x));
expect(Math.round(zoomInBox!.y - fullscreenBox!.y - fullscreenBox!.height)).toBe(18);
expect(Math.round(fullscreenGroupBox!.x)).toBe(Math.round(zoomGroupBox!.x));
expect(Math.round(fullscreenGroupBox!.width)).toBe(Math.round(zoomGroupBox!.width));
expect(Math.round(zoomGroupBox!.y - fullscreenGroupBox!.y - fullscreenGroupBox!.height)).toBe(8);
expect(Math.round(blockBox!.x + blockBox!.width - fullscreenGroupBox!.x - fullscreenGroupBox!.width)).toBe(18);
expect(Math.round(graphBox!.x + graphBox!.width - zoomGroupBox!.x - zoomGroupBox!.width)).toBe(8);
};
assertControlGeometry(inlineControls);
await assertInlineControlRail(diagram);
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);
const assertFullscreenCloseFrame = async () => {
const close = fullscreen.getByRole("button", { name: "退出全屏", exact: true });
const frame = await close.evaluate((button) => {
const rect = button.getBoundingClientRect();
return {
top: rect.top,
right: window.innerWidth - rect.right,
boxShadow: getComputedStyle(button).boxShadow
};
});
expect(Math.round(frame.top)).toBe(21);
expect(Math.round(frame.right)).toBe(21);
expect(frame.boxShadow).not.toBe("none");
};
await assertFullscreenCloseFrame();
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("现场采样需要派单");
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();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await page.setViewportSize({ width: 1280, height: 720 });
await expect(review).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(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 });
const mobilePanel = page.getByRole("region", { name: "工况任务", exact: true });
if (!await mobilePanel.isVisible()) await page.getByRole("button", { name: "打开工况任务", exact: true }).click();
const sheet = page.getByRole("region", { name: "工况任务抽屉" });
if (await sheet.getAttribute("data-snap") === "half") {
await page.getByRole("button", { name: /工况任务抽屉高度/ }).click();
}
await expect(sheet).toHaveAttribute("data-snap", "full");
const mobileExpand = mobilePanel.getByRole("button", { name: "展开工况任务" });
if (await mobileExpand.isVisible()) await mobileExpand.click();
await mobilePanel.getByRole("button", { name: /调查任务/ }).click();
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));
await assertInlineControlRail(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 assertFullscreenCloseFrame();
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 }).evaluate((button) => button.click());
await expect(fullscreen).toHaveCount(0);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
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();
});