import { expect, test } from "@playwright/test"; import type { OperationalTask, OperationalTaskAction, OperationalTaskSummary } from "../../src/features/workbench/api/operational-task-client"; import { mockAgentApi } from "./support/mock-agent-api"; const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e"; const taskId = "72000000-0000-4000-8000-000000000001"; const appointmentTaskId = "72000000-0000-4000-8000-000000000002"; const actionId = "73000000-0000-4000-8000-000000000001"; test("审批动作展示中文参数和证据,viewer 不可审批,冲突后可核对未知回执", async ({ page }, testInfo) => { test.setTimeout(60_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_PERIPHERAL_SAMPLE_API_BASE_URL: "http://sample.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 baseAction: OperationalTaskAction = { action_id: actionId, task_id: taskId, version: 3, kind: "issue_command", node_id: null, reason: "隔离疑似漏损支路前需要调度员确认阀门目标与开度", payload: { command_type: "set_valve", target: { kind: "valve", id: "V-102" }, parameters: { opening: 35 }, impact_summary: "隔离疑似漏损支路,可能短时影响演示小区供水压力", preconditions: ["上游压力不低于 28m", "现场人员已到位"], expires_at: "2026-09-23T09:00:00Z" }, evidence_refs: ["e-pressure"], required_approval_permission: "operational_action.command.approve", required_execution_permission: "operational_action.command.execute", approval_status: "pending", execution_status: "not_submitted", execution_request_id: null, proposed_by: "agent", proposed_at: "2026-09-23T08:25:00Z", decided_by: null, decided_at: null, decision_comment: null, executed_by: null, executed_at: null, receipt: null, execution_detail: null }; let task: OperationalTask = { project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "burst_report", lifecycle_status: "waiting_human", active_run: null, status: "waiting_human", priority: "urgent", revision: 2, execution_generation: 2, execution_request_id: "74000000-0000-4000-8000-000000000001", title: "阀门调节审批", summary: "爆管事件调查需要确认隔离阀门动作", source: { kind: "operator", external_id: null, external_revision: null, event_kind: "burst_report" }, trigger: { kind: "manual", occurred_at: "2026-09-23T08:20:00Z", detail: "演示路与样板路交叉口" }, scheduled_for: null, agent_session_id: "approval-session", agent_lease_expires_at: null, current_plan_revision: null, current_report_revision: 1, created_at: "2026-09-23T08:20:00Z", updated_at: "2026-09-23T08:25:00Z", latest_plan: null, latest_report: { revision: 1, execution_generation: 1, source_session_id: "approval-session", published_at: "2026-09-23T08:24:00Z", published_by: "agent", report: { title: "爆管事件调查报告", current_conclusion: "疑似支路漏损,阀门动作尚未执行。", conclusion_boundary: "等待人工确认和设备回执。", evidence_refs: ["e-pressure"], recommendations: ["确认 V-102 目标与开度后再执行"], pending_items: ["调度员确认阀门动作"] } }, evidence: [{ evidence_id: "e-pressure", kind: "system_data", summary: "V-102 下游压力在五分钟内下降 8.2m", source_label: "SCADA 压力监测", source_ref: "PRESSURE-V102", observed_at: "2026-09-23T08:18:00Z", recorded_at: "2026-09-23T08:23:00Z", recorded_by: "agent", has_snapshot: false }], actions: [baseAction], permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }, work_order_snapshot: null }; const appointmentTask: OperationalTask = { ...task, task_id: appointmentTaskId, task_type: "external_work_order", workflow: "general", lifecycle_status: "open", active_run: null, status: "queued", priority: "normal", execution_request_id: "74000000-0000-4000-8000-000000000002", title: "城北阀井预约维修", summary: "外围工单同步的预约维修任务", source: { kind: "external_system", external_id: "sample-work-order-1", external_revision: 2, event_kind: "general" }, trigger: { kind: "external_sync", occurred_at: "2026-09-23T08:30:00Z", detail: "预约 09/23 18:00 由北片区维修组处理" }, scheduled_for: "2026-09-23T10:00:00Z", agent_session_id: null, current_report_revision: null, latest_report: null, evidence: [], actions: [], work_order_snapshot: { work_order_id: "50000000-0000-4000-8000-000000000001", revision: 2, title: "城北阀井预约维修", summary: "编辑后的预约维修说明", status: "open", priority: "normal", assignee: "测试班组乙", scheduled_at: "2026-09-23T10:00:00Z", work_order_type: "general", location: "联调测试点东侧", scenario_code: "APPOINTMENT-TEST-001", feedback_requirements: ["回填现场处理结果"], activity: [], origin: "synthetic", updated_at: "2026-09-23T08:30:00Z" } }; const summary = (item: OperationalTask): OperationalTaskSummary => { const { latest_plan, latest_report, evidence, actions, permissions, work_order_snapshot, ...taskSummary } = item; void latest_plan; void latest_report; void evidence; void actions; void permissions; void work_order_snapshot; return taskSummary; }; let scheduleRequests = 0; const runtimeSnapshot = { 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: "2026-09-23T08:30:00Z", conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }; let snapshotRequests = 0; await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) => { snapshotRequests += 1; return route.fulfill({ json: { schema: "workbench-sync-snapshot@1", event_cursor: 0, runtime: runtimeSnapshot, tasks: [summary(task), summary(appointmentTask)], notifications: [], generated_at: "2026-09-23T08:30:00Z" } }); }); await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) ); await page.route("http://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }) ); await page.route("http://server.test/api/v1/operational-tasks**", async (route) => { const request = route.request(); const pathname = new URL(request.url()).pathname; if (pathname.endsWith("/operational-tasks/schedules")) { scheduleRequests += 1; return route.fulfill({ json: { items: [] } }); } if (request.method() === "GET" && pathname.endsWith("/operational-tasks")) { return route.fulfill({ json: { items: [summary(task), summary(appointmentTask)], limit: 50, offset: 0, next_offset: null } }); } if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${taskId}`)) { return route.fulfill({ json: task }); } if (request.method() === "GET" && pathname.endsWith(`/operational-tasks/${appointmentTaskId}`)) { return route.fulfill({ json: appointmentTask }); } if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/decision`)) { return route.fulfill({ status: 409, json: { detail: "动作版本冲突:expected 3, actual 4" } }); } if (request.method() === "POST" && pathname.endsWith(`/actions/${actionId}/reconcile`)) { const receiptAction = { ...task.actions[0], version: 5, execution_status: "succeeded", receipt: { adapter: "scada-command-adapter", receipt_id: "SCADA-RECEIPT-20260923-01", submitted_at: "2026-09-23T08:28:00Z", completed_at: "2026-09-23T08:29:00Z", external_status: "completed", detail: "V-102 开度已调整至 35%" }, execution_detail: "设备回执确认完成" }; task = { ...task, actions: [receiptAction] }; return route.fulfill({ json: receiptAction }); } return route.fulfill({ status: 404, json: {} }); }); const openTask = async () => { await page.getByRole("button", { name: "工况任务", exact: true }).click(); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" }); if (await expandButton.isVisible()) await expandButton.click(); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); await conditionPanel.getByText("阀门调节审批", { exact: true }).first().click(); return conditionPanel; }; await page.goto("/"); const alertTrigger = page.getByRole("button", { name: "查看异常处置面板,当前 0 条待复核工况" }).first(); await expect(alertTrigger).toBeVisible(); await alertTrigger.click(); await expect(page.getByText("当前无待复核工况", { exact: true }).first()).toBeVisible(); await expect(page.getByText("来源:未配置告警源", { exact: true })).toBeVisible(); await page.keyboard.press("Escape"); const reconnectNotice = page.locator("[data-sonner-toast]").filter({ hasText: "业务同步正在重连" }); await expect(reconnectNotice).toBeVisible(); await expect(reconnectNotice).toContainText("当前数据可能陈旧,连接恢复后将自动更新。"); await expect(page.getByText("正在重连,业务数据可能陈旧", { exact: true })).toHaveCount(0); await page.evaluate(() => document.fonts.ready); await testInfo.attach("workbench-reconnect-notice", { body: await page.screenshot({ path: testInfo.outputPath("workbench-reconnect-notice.png") }), contentType: "image/png" }); let conditionPanel = await openTask(); await expect(conditionPanel.getByRole("navigation", { name: "工况任务分类" })).toBeVisible(); await expect(conditionPanel.getByRole("button", { name: /定时任务/ })).toContainText("模拟数据"); await expect(conditionPanel.getByRole("button", { name: /调查任务/ })).toContainText("后端"); await expect(conditionPanel.getByRole("button", { name: /预约任务/ })).toContainText("后端"); const conditionRefreshButton = conditionPanel.getByRole("button", { name: "刷新工况任务" }); await expect(conditionRefreshButton).toBeVisible(); const refreshStartedAt = Date.now(); await conditionRefreshButton.click(); await expect.poll(() => snapshotRequests).toBe(2); const refreshingButton = conditionPanel.getByRole("button", { name: "正在刷新工况任务" }); await expect(refreshingButton).toBeDisabled(); await refreshingButton.evaluate((button: HTMLButtonElement) => button.click()); expect(snapshotRequests).toBe(2); await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 }); expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900); await expect(conditionPanel.getByRole("button", { name: "配置定时流程" })).toHaveCount(0); await expect(conditionPanel.getByRole("button", { name: "创建定时流程" })).toHaveCount(0); await conditionPanel.getByRole("button", { name: /预约任务/ }).click(); await expect(conditionPanel.getByText("最近工况", { exact: true })).toHaveCount(1); await expect(conditionPanel.getByText("城北阀井预约维修", { exact: true }).first()).toBeVisible(); await conditionPanel.getByText("城北阀井预约维修", { exact: true }).first().click(); await expect(conditionPanel.getByText("复令要求", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("回填现场处理结果", { exact: true })).toBeVisible(); const appointmentDetail = conditionPanel.getByTestId("scheduled-condition-detail-scroll"); await expect(appointmentDetail.getByText("工单报告", { exact: true })).toBeVisible(); await expect(appointmentDetail.getByText("派单信息", { exact: true })).toBeVisible(); await expect(appointmentDetail.getByText("活动记录", { exact: true })).toHaveCount(0); await expect(appointmentDetail.getByText("关键证据", { exact: true })).toHaveCount(0); await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible(); await expect(appointmentDetail.getByText("联调测试点东侧", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("编辑后的预约维修说明", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("预约 09/23 18:00 由北片区维修组处理", { exact: true })).toHaveCount(0); await page.waitForTimeout(3_500); await page.addStyleTag({ content: "[data-sonner-toaster] { display: none !important; }" }); await page.evaluate(() => document.fonts.ready); await page.screenshot({ path: "/tmp/tjwater-operational-ui-review/task-categories-desktop.png" }); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); expect(scheduleRequests).toBe(0); await expect(conditionPanel.getByText("上一轮报告,当前任务待人工", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText(/第\s*[12]\s*代|generation/i)).toHaveCount(0); const actionCard = conditionPanel.locator(`[data-action-id="${actionId}"]`); await expect(actionCard.getByText("命令类型", { exact: true })).toBeVisible(); await expect(actionCard.getByText("对象类型", { exact: true })).toBeVisible(); await expect(actionCard.getByText("目标对象", { exact: true })).toBeVisible(); await expect(actionCard.getByText("执行参数", { exact: true })).toBeVisible(); await expect(actionCard.getByText("影响说明", { exact: true })).toBeVisible(); await expect(actionCard.getByText("调节阀门", { exact: true })).toBeVisible(); await expect(actionCard.getByText("阀门", { exact: true })).toBeVisible(); await expect(actionCard.getByText("开度 35%", { exact: true })).toBeVisible(); await expect(actionCard.getByText("确认依据", { exact: true })).toBeVisible(); await expect(actionCard.getByText(/V-102 下游压力.*SCADA 压力监测/)).toBeVisible(); await expect(actionCard.getByText(/当前账号仅可查看/)).toBeVisible(); await expect(actionCard.getByText(/operational_action\.command\.approve/)).toHaveCount(0); await expect(actionCard.getByRole("button", { name: "批准下发命令" })).toHaveCount(0); await page.evaluate(() => document.fonts.ready); await testInfo.attach("approval-viewer-desktop", { body: await actionCard.screenshot({ path: testInfo.outputPath("approval-viewer-desktop.png") }), contentType: "image/png" }); task = { ...task, permissions: { ...task.permissions, can_decide_commands: true } }; await page.reload(); conditionPanel = await openTask(); await conditionPanel.getByRole("button", { name: "批准下发命令" }).click(); await expect(conditionPanel.getByRole("alert")).toContainText("动作版本冲突:expected 3, actual 4"); const refreshButton = conditionPanel.getByRole("button", { name: "刷新后重新确认" }); task = { ...task, actions: [{ ...task.actions[0], version: 4, approval_status: "approved", execution_status: "unknown", execution_request_id: "75000000-0000-4000-8000-000000000001", decided_by: "dispatcher", decided_at: "2026-09-23T08:27:00Z" }], permissions: { ...task.permissions, can_execute_commands: true } }; await refreshButton.click(); const reconcileButton = conditionPanel.getByRole("button", { name: "核对执行回执" }); await expect(reconcileButton).toBeVisible(); await reconcileButton.click(); const receipt = conditionPanel.getByRole("region", { name: "执行回执" }); await expect(receipt).toContainText("SCADA-RECEIPT-20260923-01"); await expect(receipt).toContainText("已完成"); await expect(receipt).toContainText("V-102 开度已调整至 35%"); await testInfo.attach("approval-receipt-desktop", { body: await actionCard.screenshot({ path: testInfo.outputPath("approval-receipt-desktop.png") }), contentType: "image/png" }); }); test("后端没有调查任务时显示空态,分类标签按压不缩放", async ({ page }, testInfo) => { test.setTimeout(60_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://server.test/api/v1/operational-task-notifications**", (route) => route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } }) ); await page.route("http://server.test/api/v1/operational-tasks**", (route) => route.fulfill({ json: { items: [], limit: 50, offset: 0, next_offset: null } }) ); 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: "2026-09-23T08:30:00Z", conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: [] }, tasks: [], notifications: [], generated_at: "2026-09-23T08:30:00Z" } }) ); await page.route("http://server.test/api/v1/workbench/events**", (route) => route.fulfill({ contentType: "text/event-stream", body: ": test keepalive\n\n" }) ); await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 })); await page.goto("/"); await page.getByRole("button", { name: "工况任务", exact: true }).press("Enter"); const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true }); await conditionPanel.getByRole("button", { name: "展开工况任务" }).click(); await conditionPanel.getByRole("button", { name: /调查任务/ }).click(); await expect(conditionPanel.getByText("暂无任务", { exact: true })).toBeVisible(); await expect(conditionPanel.getByText("新任务进入当前分类后会出现在这里。", { exact: true })).toBeVisible(); for (const width of [1280, 375, 320]) { await page.setViewportSize({ width, height: 900 }); if (width <= 375) { const openMobileTasks = page.getByRole("button", { name: "打开工况任务", exact: true }); if (await openMobileTasks.isVisible()) await openMobileTasks.click(); const expandMobile = conditionPanel.getByRole("button", { name: "展开工况任务" }); if (await expandMobile.isVisible()) await expandMobile.click(); } const categories = conditionPanel.getByRole("navigation", { name: "工况任务分类" }); for (const label of ["定时任务", "调查任务", "预约任务"]) { const tab = categories.getByRole("button", { name: new RegExp(label) }); await tab.hover(); const before = await tab.boundingBox(); expect(before).not.toBeNull(); await page.mouse.down(); try { await tab.evaluate(async (element) => { await Promise.all(element.getAnimations().map((animation) => animation.finished)); }); await expect.poll(() => tab.evaluate((element) => getComputedStyle(element).scale)).toBe("none"); const pressed = await tab.boundingBox(); expect(pressed).not.toBeNull(); expect(Math.abs(pressed!.width - before!.width)).toBeLessThan(0.5); expect(Math.abs(pressed!.height - before!.height)).toBeLessThan(0.5); } finally { await page.mouse.up(); } await expect(tab).toHaveAttribute("aria-current", "page"); } await categories.screenshot({ path: testInfo.outputPath(`task-tabs-${width}.png`) }); } await page.emulateMedia({ reducedMotion: "reduce" }); const investigationTab = conditionPanel.getByRole("navigation", { name: "工况任务分类" }) .getByRole("button", { name: /调查任务/ }); await investigationTab.focus(); await page.keyboard.press("Enter"); await expect(investigationTab).toHaveAttribute("aria-current", "page"); await expect(investigationTab).toBeFocused(); await expect.poll(() => investigationTab.evaluate((element) => getComputedStyle(element).transitionProperty)).toBe("none"); });