455 lines
21 KiB
TypeScript
455 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();
|
|
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());
|
|
await page.waitForTimeout(500);
|
|
expect(snapshotRequests).toBe(2);
|
|
await expect(conditionRefreshButton).toBeEnabled({ timeout: 4_000 });
|
|
expect(Date.now() - refreshStartedAt).toBeGreaterThanOrEqual(2_900);
|
|
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");
|
|
});
|