395 lines
30 KiB
TypeScript
395 lines
30 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
|
|
});
|
|
}
|
|
};
|
|
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("现场采样需要派单");
|
|
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));
|
|
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.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();
|
|
});
|