Static fulfilled SSE bodies closed immediately and made ordinary UI cases reconnect. Serve persistent fixture streams, retain outage and cursor recovery coverage, and update legacy approval and investigation assertions for current disclosures and SVG diagrams. Validation: 12 related browser scenarios, 877 unit tests, type checking, scoped ESLint, and diff checks. Unrelated SCADA replay changes remain outside this commit.
448 lines
33 KiB
TypeScript
448 lines
33 KiB
TypeScript
import { responsesFixture } from "./support/responses-fixture";
|
|
import { expect, test } from "@playwright/test";
|
|
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
|
|
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 page.emulateMedia({ reducedMotion: "reduce" });
|
|
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[] = [{
|
|
notification_id: "87000000-0000-4000-8000-000000000002", task_id: taskId,
|
|
kind: "action_approval_pending", title: "待确认现场采样工单", description: "请审核派单内容",
|
|
lifecycle_status: "active", subject_type: "action", subject_id: actionId,
|
|
report_reference: null, resolved_at: null, resolution_reason: null,
|
|
status: "unread", version: 1, required_permission: null, created_at: now, read_at: null, read_by: null
|
|
}];
|
|
const summary = (item: OperationalTask): OperationalTaskSummary => {
|
|
const { latest_plan, latest_report, latest_progress, evidence, actions, access_requests, permissions, work_order_snapshot, ...value } = item;
|
|
void latest_plan; void latest_report; void latest_progress; 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 mockWorkbenchEventStream(page);
|
|
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]:has([data-investigation-diagram-control])").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(3);
|
|
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 zoomGroup = zoomInButton.locator("..");
|
|
const [blockBox, graphBox, fullscreenBox, zoomInBox, zoomGroupBox] = await Promise.all([
|
|
mermaidBlock.boundingBox(), graph.boundingBox(), fullscreenButton.boundingBox(), zoomInButton.boundingBox(),
|
|
zoomGroup.boundingBox()
|
|
]);
|
|
expect(blockBox).not.toBeNull();
|
|
expect(graphBox).not.toBeNull();
|
|
expect(fullscreenBox).not.toBeNull();
|
|
expect(zoomInBox).not.toBeNull();
|
|
expect(zoomGroupBox).not.toBeNull();
|
|
expect(Math.round(graphBox!.y - blockBox!.y)).toBeLessThanOrEqual(10);
|
|
expect(fullscreenBox!.y + fullscreenBox!.height).toBeLessThanOrEqual(blockBox!.y);
|
|
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.getByRole("dialog", { 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();
|
|
const panel = button.closest('[role="dialog"]')!.getBoundingClientRect();
|
|
return {
|
|
top: rect.top - panel.top,
|
|
right: panel.right - rect.right
|
|
};
|
|
});
|
|
expect(frame.top).toBeGreaterThanOrEqual(12);
|
|
expect(frame.right).toBeGreaterThanOrEqual(12);
|
|
};
|
|
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();
|
|
const upstreamLink = diagram.getByRole("button", { name: /^上游监测 · .* · 1 条依据$/ });
|
|
await upstreamLink.click();
|
|
await expect(upstreamLink).toHaveAttribute("aria-pressed", "false");
|
|
await upstreamLink.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(upstreamLink).toHaveAttribute("aria-pressed", "true");
|
|
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 命令面板" });
|
|
await expect(agentPanel.getByRole("region", { name: "任务协作状态" })).toHaveCount(0);
|
|
await expect(agentPanel.getByRole("button", { name: "确认并创建预约工单" })).toHaveCount(0);
|
|
await expect(agentPanel.getByRole("group", { name: "对话引用" })).toBeVisible();
|
|
await agentPanel.screenshot({ path: testInfo.outputPath("discussion-without-fixed-review.png") });
|
|
// Narrow-screen conversation layout is covered by investigation-main-chat.e2e.ts.
|
|
await page.getByRole("button", { name: /查看待办与告警/ }).click();
|
|
await page.getByRole("menuitem").filter({ hasText: "待确认现场采样工单" }).click();
|
|
const pending = panel.getByLabel("当前需处理", { exact: true });
|
|
const review = pending.locator(`[data-action-id="${actionId}"]`);
|
|
await expect(review).toBeVisible();
|
|
await expect(review.getByRole("heading", { name: "关联预约工单" })).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();
|
|
// Live is the default; only explicitly choosing a historical version freezes it.
|
|
await expect(panel.getByRole("button", { name: "查看新报告" })).toHaveCount(0);
|
|
await expect.poll(() => notifications[0].status).toBe("read");
|
|
await expect(panel.getByText("本轮已结束 · 无法形成结论", { exact: true })).toBeVisible();
|
|
// Existing investigations may only have a same-round saved plan, not an embedded diagram.
|
|
await expect(diagram).toBeAttached();
|
|
await diagram.scrollIntoViewIfNeeded();
|
|
await expect(diagram.locator("svg[id]")).toHaveCount(1);
|
|
await expect(diagram).toContainText("多源数据核对");
|
|
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
|
|
await testInfo.attach("legacy-investigation-diagram", { body: await diagram.screenshot({
|
|
path: testInfo.outputPath("legacy-investigation-diagram.png") }), contentType: "image/png" });
|
|
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.getByRole("button", { name: "展开任务时间线" }).click();
|
|
const timeline = page.getByRole("dialog", { name: "任务时间线" }).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 page.getByRole("dialog", { name: "任务时间线" }).getByRole("button", { name: "关闭详情" }).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.getByRole("button", { name: "展开报告版本" }).click();
|
|
const versions = page.getByRole("dialog", { name: "报告版本" });
|
|
await expect(versions.getByRole("button", { name: /报告第 2 版 · 最新版/ })).toBeVisible();
|
|
await versions.getByRole("button", { name: /第 1 轮 · 报告第 1 版/ }).click();
|
|
await expect(panel.getByLabel("任务报告", { exact: true })
|
|
.getByText("上游记录未见同期异常,需现场核查用户端。", { exact: true })).toBeVisible();
|
|
await page.route("**/api/v1/responses", async (route) => {
|
|
if (route.request().method() !== "POST") return route.fallback();
|
|
expect(route.request().postDataJSON().tjwater.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: responsesFixture([{ 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" }]) });
|
|
});
|
|
await panel.getByLabel("主要依据").getByRole("button", { name: "围绕此证据提问" }).click();
|
|
await expect(agentPanel.getByRole("group", { name: "对话引用" })).toContainText("报告第 1 版");
|
|
await expect(agentPanel.locator("textarea")).toBeFocused();
|
|
await expect(agentPanel.locator("textarea")).toHaveValue(/请解释.*这条证据的范围、来源和判断影响/);
|
|
await agentPanel.getByRole("button", { name: "发送 Agent 指令" }).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();
|
|
});
|