import { responsesFixture } from "./support/responses-fixture"; 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, 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 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(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(); 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(); // 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(); });