Files
next-tjwater-frontend/tests/browser/operational-task-approval.e2e.ts
T

447 lines
21 KiB
TypeScript

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_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("/");
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();
await conditionRefreshButton.click();
await expect.poll(() => snapshotRequests).toBe(2);
await conditionPanel.getByRole("button", { name: "配置定时流程" }).click();
await conditionPanel.getByLabel("定时流程名称").fill("夜间阀门巡检");
await conditionPanel.getByLabel("任务说明").fill("每十五分钟检查预约维修涉及的边界阀门。");
await conditionPanel.getByRole("button", { name: "创建定时流程" }).click();
await expect(conditionPanel.getByText("夜间阀门巡检", { exact: true }).first()).toBeVisible();
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();
await expect(conditionPanel.getByText("测试班组乙", { exact: true }).first()).toBeVisible();
await expect(conditionPanel.getByText(/位置:联调测试点东侧/)).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 });
const expandButton = conditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandButton.isVisible()) await expandButton.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");
});