feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
|
||||
|
||||
for (const width of [1280, 375, 320]) {
|
||||
test(`待办和告警可独立移除且刷新后保持 ${width}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const task = investigationFixture(1, "通知移除测试调查");
|
||||
const notification: OperationalTaskNotification = {
|
||||
notification_id: "96000000-0000-4000-8000-000000000011", task_id: task.task_id,
|
||||
kind: "human_input_required", title: "待补充现场流量记录", description: "请核对现场记录后补充资料。",
|
||||
required_permission: null, lifecycle_status: "active", subject_type: "question", subject_id: null,
|
||||
resolved_at: null, resolution_reason: null, status: "unread", version: 1,
|
||||
created_at: task.created_at, read_at: null, read_by: null
|
||||
};
|
||||
await mockInvestigationPresentation(page, [task], [], [notification]);
|
||||
const alert = { id: "pressure-alert-1", source: { kind: "scada_replay", replay_id: "replay-1", rule_id: "pressure-low", batch_sequence: 1 }, condition_id: `operational-task:${task.task_id}`,
|
||||
title: "SCADA 压力持续偏低需要核查现场情况", description: "压力监测点连续出现异常读数,请查看关联调查。",
|
||||
occurred_at: task.created_at, severity: "warning" };
|
||||
let cursor = 10;
|
||||
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
||||
schema: "workbench-sync-snapshot@1", event_cursor: cursor, generated_at: task.created_at,
|
||||
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: "ready", generated_at: task.created_at, conditions: [], alerts: [alert], scenarios: [], latest_analysis: null, work_orders: [] },
|
||||
tasks: [Object.fromEntries(Object.entries(task).filter(([key]) =>
|
||||
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions", "questions"].includes(key)))], notifications: [notification]
|
||||
} }));
|
||||
const writes: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith("https://tjwater-api.invalid/") && ["POST", "PUT", "PATCH", "DELETE"].includes(request.method())) writes.push(request.url());
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const trigger = page.getByRole("button", { name: /查看待办与告警/ });
|
||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 2 条待处理事项");
|
||||
await trigger.click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
const removeTask = page.getByRole("menuitem", { name: `移除通知:${notification.title}` });
|
||||
await expect(removeTask).toBeVisible();
|
||||
const openTask = page.getByRole("menuitem", { name: `打开通知:${notification.title}` });
|
||||
const actions = await openTask.evaluate((element) => {
|
||||
const open = element.getBoundingClientRect();
|
||||
const remove = element.nextElementSibling!.getBoundingClientRect();
|
||||
return { topDifference: Math.abs(open.top - remove.top), gap: remove.left - open.right };
|
||||
});
|
||||
expect(actions.topDifference).toBeLessThan(1);
|
||||
expect(actions.gap).toBeGreaterThanOrEqual(0);
|
||||
const box = await menu.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
await testInfo.attach(`alert-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath("alert-menu.png"), style: "[data-sonner-toaster] { visibility: hidden !important; }" }), contentType: "image/png" });
|
||||
await removeTask.click();
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项");
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toHaveCount(0);
|
||||
// A repeated business snapshot must not restore the removed notification.
|
||||
await page.evaluate((event) => {
|
||||
(window as unknown as { emitInvestigationTestEvent: (value: unknown) => void }).emitInvestigationTestEvent(event);
|
||||
}, { schema: "workbench-event@1", event_seq: ++cursor, event_id: "96000000-0000-4000-8000-000000000012",
|
||||
project_id: task.project_id, topic: "workbench.alert.changed", aggregate_type: "task_alert",
|
||||
aggregate_id: alert.id, aggregate_revision: 2, occurred_at: task.created_at, metadata: {} });
|
||||
await page.getByRole("menuitem", { name: `移除通知:${alert.title}` }).focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(menu).toContainText("当前无待处理事项");
|
||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项");
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("menu")).toContainText("当前无待处理事项");
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -134,6 +134,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
}
|
||||
if (method === "PATCH" && path.endsWith(`/actions/${actionId}/draft`)) {
|
||||
const body = route.request().postDataJSON();
|
||||
expect(body.payload.scheduled_at).toBe("2026-07-16T05:00:00.000Z");
|
||||
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 };
|
||||
@@ -309,7 +310,7 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
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();
|
||||
await page.getByRole("menuitem", { name: "打开通知:待确认现场采样工单" }).click();
|
||||
const pending = panel.getByLabel("当前需处理", { exact: true });
|
||||
const review = pending.locator(`[data-action-id="${actionId}"]`);
|
||||
await expect(review).toBeVisible();
|
||||
@@ -320,6 +321,19 @@ test("调查报告展示 SVG 脉络、处置进展和报告版本,缺数正常
|
||||
await expect(review).toContainText("工单归属");
|
||||
await expect(review).toContainText("预约任务");
|
||||
await review.getByRole("textbox", { name: "工单标题" }).fill("现场采样修订稿");
|
||||
await review.getByRole("button", { name: "预约时间", exact: true }).click();
|
||||
const appointmentCalendar = page.getByRole("dialog", { name: "选择预约时间", exact: true });
|
||||
await appointmentCalendar.getByRole("textbox", { name: "年份", exact: true }).fill("2026");
|
||||
await appointmentCalendar.getByRole("combobox", { name: "月份", exact: true }).click();
|
||||
await page.getByRole("option", { name: "7月", exact: true }).click();
|
||||
await appointmentCalendar.getByRole("gridcell", { name: "2026年7月16日", exact: true }).click();
|
||||
await appointmentCalendar.getByRole("combobox", { name: "小时", exact: true }).click();
|
||||
await page.getByRole("option", { name: "13", exact: true }).click();
|
||||
await appointmentCalendar.getByRole("combobox", { name: "分钟", exact: true }).click();
|
||||
await page.getByRole("option", { name: "00", exact: true }).click();
|
||||
await appointmentCalendar.getByRole("button", { name: "确认", exact: true }).click();
|
||||
await expect(review.getByRole("button", { name: "预约时间", exact: true })).toContainText("2026-07-16 13:00");
|
||||
await testInfo.attach("appointment-time-edit", { body: await review.screenshot({ path: testInfo.outputPath("appointment-time-edit.png") }), contentType: "image/png" });
|
||||
await review.getByRole("button", { name: "保存草稿" }).click();
|
||||
await expect(panel.getByText("现场采样修订稿").first()).toBeVisible();
|
||||
await review.getByRole("button", { name: "确认并创建预约工单" }).click();
|
||||
|
||||
@@ -57,7 +57,7 @@ for (const kind of ["peripheral", "dev"] as const) {
|
||||
if (kind === "dev") {
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await expect.poll(() => page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.classList.contains("maplibregl-canvas") ?? false, point)).toBe(true);
|
||||
await page.getByRole("button", { name: "关闭外围系统", exact: true }).click();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮", exact: true })).toBeEnabled();
|
||||
}
|
||||
|
||||
@@ -24,12 +24,28 @@ async function setup(page: Page) {
|
||||
|
||||
async function openSearch(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const trigger = page.getByRole("button", { name: "地址定位:查询地址并定位到地图" });
|
||||
const trigger = page.getByRole("button", { name: "定位:查询地址或管网要素" });
|
||||
await expect(trigger).toBeEnabled({ timeout: 20_000 });
|
||||
await trigger.click();
|
||||
return page.getByRole("region", { name: "地址定位", exact: true });
|
||||
return page.getByRole("region", { name: "定位", exact: true });
|
||||
}
|
||||
|
||||
test("location tabs preserve inputs and end map picking when switching to network features", async ({ page }) => {
|
||||
await setup(page);
|
||||
const panel = await openSearch(page);
|
||||
await panel.getByLabel("查询地址").fill("南桥路");
|
||||
const pick = panel.getByRole("button", { name: "从地图取点查找地名" });
|
||||
await pick.click();
|
||||
await expect(pick).toHaveAttribute("aria-pressed", "true");
|
||||
await panel.getByRole("tab", { name: "管网要素" }).click();
|
||||
await panel.getByLabel("要素 ID(可多个)").fill("P-17,P-28");
|
||||
await panel.getByRole("tab", { name: "地址地名" }).click();
|
||||
await expect(pick).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(panel.getByLabel("查询地址")).toHaveValue("南桥路");
|
||||
await panel.getByRole("tab", { name: "管网要素" }).click();
|
||||
await expect(panel.getByLabel("要素 ID(可多个)")).toHaveValue("P-17,P-28");
|
||||
});
|
||||
|
||||
for (const width of [1280, 1023, 375, 320]) {
|
||||
test(`place search selects and switches candidates at ${width}px`, async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
@@ -319,7 +335,7 @@ test("map point lookup recovers from an out of scope point and stops when closin
|
||||
await canvas.click({ position: { x: 440, y: 180 } });
|
||||
await expect(panel.getByRole("region", { name: "定位结果" })).toContainText("上海市奉贤区南桥路1号");
|
||||
await expect(panel.getByRole("button", { name: "从地图取点查找地名" })).toHaveAttribute("aria-pressed", "false");
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
await page.getByRole("button", { name: "定位:查询地址或管网要素" }).click();
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(canvas).not.toHaveCSS("cursor", "crosshair");
|
||||
await canvas.click({ position: { x: 430, y: 190 } });
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
|
||||
|
||||
const chart = (index: number) => ({
|
||||
action: "open_chart", chart_id: "pressure", evidence_refs: ["synthetic:pressure"],
|
||||
chart: { title: `合成压力分析 ${index}`, chart_type: "line", x_data: ["10:00", "10:15"],
|
||||
series: [{ name: "压力(m)", data: [30, 30 - index] }] }
|
||||
});
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => {
|
||||
const task = investigationFixture(1, "图表自动展示核查");
|
||||
const other = investigationFixture(2, "另一项调查");
|
||||
const mock = await mockInvestigationPresentation(page, [task, other]);
|
||||
await selectInvestigation(page, task.title);
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
|
||||
const collapse = panel.getByRole("button", { name: "收起展示", exact: true });
|
||||
await collapse.focus();
|
||||
await mock.emit(task, chart(1));
|
||||
let popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
|
||||
await expect(collapse).toBeFocused();
|
||||
const heading = popup.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小");
|
||||
await heading.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const moved = (await popup.boundingBox())!;
|
||||
await mock.emit(task, chart(2));
|
||||
popup = page.getByRole("dialog", { name: "合成压力分析 2", exact: true });
|
||||
await expect(popup).toBeVisible();
|
||||
expect((await popup.boundingBox())!.x).toBe(moved.x);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await mock.emit(task, { action: "zoom_to_map", center: [121.48, 31.1], zoom: 13 });
|
||||
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("3 个动作");
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: "放大查看 合成压力分析 2", exact: true }).click();
|
||||
await expect(popup).toBeVisible();
|
||||
await testInfo.attach("live-chart-window", { body: await page.screenshot({ path: testInfo.outputPath("live-chart-window.png") }), contentType: "image/png" });
|
||||
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await mock.emit(task, chart(3));
|
||||
await expect(page.getByRole("dialog", { name: "合成压力分析 3", exact: true })).toBeVisible();
|
||||
await mock.emit(task, { action: "close_chart", chart_id: "pressure" });
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await mock.emit(task, chart(4));
|
||||
await expect(page.getByRole("dialog", { name: "合成压力分析 4", exact: true })).toBeVisible();
|
||||
await page.getByRole("region", { name: "工况任务", exact: true }).getByText(other.title, { exact: true }).first().click();
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("playback windows follow saved snapshots while live results remain separate", async ({ page }, testInfo) => {
|
||||
const task = investigationFixture(1, "图表回放核查");
|
||||
const commands = [chart(1), { action: "zoom_to_map", center: [121.48, 31.1], zoom: 13 }, chart(2), { action: "close_chart", chart_id: "pressure" }];
|
||||
const saved = commands.map((command, index) => ({ task_id: task.task_id, event_seq: index + 1,
|
||||
run_id: task.active_run!.run_id, execution_generation: 1, occurred_at: task.created_at, command }));
|
||||
const mock = await mockInvestigationPresentation(page, [task], saved);
|
||||
await selectInvestigation(page, task.title);
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.getByRole("button", { name: "播放历史", exact: true })).toBeVisible();
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: "播放历史", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "暂停历史播放", exact: true }).press("Enter");
|
||||
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
|
||||
await expect(popup).toBeVisible();
|
||||
await mock.emit(task, chart(3));
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(page.getByRole("dialog", { name: "合成压力分析 3", exact: true })).toHaveCount(0);
|
||||
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
const progress = panel.getByRole("slider", { name: "历史播放进度" });
|
||||
await progress.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2.");
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("dialog", { name: "合成压力分析 2", exact: true })).toBeVisible();
|
||||
await expect(progress).toBeFocused();
|
||||
await testInfo.attach("playback-chart-window", { body: await page.screenshot({ path: testInfo.outputPath("playback-chart-window.png") }), contentType: "image/png" });
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
|
||||
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "合成压力分析 3", exact: true })).toBeVisible();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("structured analysis charts open responsive windows in local preview and history", async ({ page }, testInfo) => {
|
||||
const task = investigationFixture(1, "结构化分析展示核查");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({ record_type: "ui_block", block: {
|
||||
version: "answer-ui@1", block_id: "chart-analysis", phase: "final", fallback_text: "合成压力数据", spec: {
|
||||
root: "analysis", elements: { analysis: { type: "Chart", children: [],
|
||||
props: { ...chart(1).chart, source: "仅供浏览器测试的合成数据" } } }
|
||||
}
|
||||
} }));
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
|
||||
for (const width of [375, 320]) {
|
||||
await page.setViewportSize({ width, height: 720 });
|
||||
const box = (await popup.boundingBox())!;
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(width);
|
||||
expect(await popup.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await testInfo.attach(`structured-chart-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`structured-chart-${width}.png`) }), contentType: "image/png" });
|
||||
}
|
||||
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const preview = page.getByLabel("本地预览展示", { exact: true });
|
||||
await preview.getByRole("button", { name: "播放历史", exact: true }).click();
|
||||
await expect(popup).toBeVisible();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { INVESTIGATION_DEV_NETWORK } from "../../src/features/workbench/utils/investigation-dev-network";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
|
||||
|
||||
@@ -14,9 +15,7 @@ test("live investigation charts remain visible while Dev is open", async ({ page
|
||||
await page.getByRole("button", { name: "收起展示", exact: true }).click();
|
||||
await mock.emit(task, { action: "open_chart", chart_id: "while-collapsed", evidence_refs: ["dev:synthetic"],
|
||||
chart: { title: "收起期间的合成图表", chart_type: "line", x_data: ["08:00"], series: [{ name: "压力水头(m)", data: [32] }] } });
|
||||
const activity = page.getByRole("list", { name: "动作识别记录" });
|
||||
await expect(activity.locator("li")).toHaveCount(2);
|
||||
await expect(activity.locator("li").last()).toContainText("已应用");
|
||||
await expect(page.getByRole("region", { name: "最近展示动作" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "展开展示", exact: true }).click();
|
||||
await expect(page.locator('[data-investigation-chart="while-collapsed"]')).toBeVisible();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
@@ -32,7 +31,7 @@ test("Dev validates and previews JSONL locally, rejects invalid actions and clea
|
||||
ui_blocks: [{ evidence_refs: ["dev:report"], block: {
|
||||
version: "answer-ui@1", block_id: "saved-table", phase: "final", fallback_text: "报告候选列表", spec: {
|
||||
root: "table", elements: { table: { type: "Table", props: {
|
||||
title: "已保存的候选列表", columns: ["候选", "依据"], rows: [["DEMO-PIPE-03", "合成资料"]]
|
||||
title: "已保存的候选列表", columns: ["候选", "依据"], rows: [[INVESTIGATION_DEV_NETWORK.pipeIds[2], "合成资料"]]
|
||||
}, children: [] } }
|
||||
}
|
||||
} }] } };
|
||||
@@ -63,18 +62,19 @@ test("Dev validates and previews JSONL locally, rejects invalid actions and clea
|
||||
|
||||
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
|
||||
await expect(tools).toContainText("已识别 3 条展示指令、1 个界面块、1 条调查数据");
|
||||
await expect(tools).toContainText("已识别 2 条展示指令、1 个界面块、1 条调查数据");
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools).toContainText("本地预览完成");
|
||||
await expect(page.locator('[data-investigation-chart="dev-synthetic-pressure"]')).toBeVisible();
|
||||
await expect(page.getByLabel("本地预览展示", { exact: true }).getByRole("region", { name: "展示结果" })).toContainText("合成候选对比");
|
||||
await expect(tools.getByRole("list", { name: "动作识别记录" })).toContainText("本地测试");
|
||||
await expect(tools.getByText("原始数据检查", { exact: true })).toHaveCount(0);
|
||||
await expect(tools.getByRole("region", { name: "最近展示动作" })).toHaveCount(0);
|
||||
const sourceSize = (source: string) => page.evaluate(async (source) => {
|
||||
const map = window.__waterNetworkMap;
|
||||
const data = await (map?.getSource(source) as { getData: () => Promise<{ features: unknown[] }> } | undefined)?.getData();
|
||||
return data?.features.length ?? 0;
|
||||
}, source);
|
||||
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(1);
|
||||
await expect.poll(() => sourceSize("preview-presentation-highlights")).toBe(0);
|
||||
await expect.poll(() => sourceSize("preview-presentation-overlay")).toBe(1);
|
||||
const download = page.waitForEvent("download");
|
||||
await tools.getByRole("button", { name: "下载测试 JSONL" }).click();
|
||||
@@ -85,7 +85,7 @@ test("Dev validates and previews JSONL locally, rejects invalid actions and clea
|
||||
expect(await dev.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("button", { name: "执行本地预览", exact: true })).toBeEnabled();
|
||||
await page.locator('[data-investigation-chart="dev-synthetic-pressure"]').getByRole("img").scrollIntoViewIfNeeded();
|
||||
await page.locator('[data-investigation-chart="dev-synthetic-pressure"]').getByRole("img", { name: "本地合成压力趋势", exact: true }).scrollIntoViewIfNeeded();
|
||||
const chartViewport = (await page.getByLabel("本地预览展示", { exact: true }).locator("[data-investigation-content]").boundingBox())!;
|
||||
const plot = (await page.locator('[data-investigation-chart="dev-synthetic-pressure"]').getByRole("img", { name: "本地合成压力趋势", exact: true }).boundingBox())!;
|
||||
expect(plot.y).toBeGreaterThanOrEqual(chartViewport.y - 1);
|
||||
@@ -166,18 +166,13 @@ test("JSONL cases switch directly, reset validation and remove the previous loca
|
||||
await expect(preview).toHaveCount(0);
|
||||
await run();
|
||||
await expect(preview).toContainText("合成候选管段对比");
|
||||
await expect(tools.getByRole("region", { name: "JSONL 检查结果" })).not.toContainText("合成候选管段对比");
|
||||
await tools.getByText("原始数据检查", { exact: true }).click();
|
||||
await expect(tools.getByRole("button", { name: "查看第 2 条调查数据" })).toBeVisible();
|
||||
await expect(tools.getByText("原始数据检查", { exact: true })).toHaveCount(0);
|
||||
|
||||
await choose("隔离阀门清单");
|
||||
await run();
|
||||
await expect(preview).toContainText("合成隔离阀门清单");
|
||||
await expect(preview).not.toContainText("合成候选管段对比");
|
||||
|
||||
await choose("地图定位与高亮");
|
||||
await run();
|
||||
await expect.poll(() => scene("preview-presentation-highlights")).toHaveLength(1);
|
||||
await choose("合成影响范围");
|
||||
await expect.poll(() => scene("preview-presentation-highlights")).toEqual([]);
|
||||
await expect.poll(() => scene("preview-presentation-overlay")).toEqual([]);
|
||||
@@ -196,9 +191,9 @@ test("JSONL cases switch directly, reset validation and remove the previous loca
|
||||
|
||||
await choose("纯 JSONL 数据");
|
||||
await run();
|
||||
await expect(preview).toContainText("DEMO-PIPE-03");
|
||||
await expect(preview).toContainText(INVESTIGATION_DEV_NETWORK.pipeIds[2]);
|
||||
await expect(page.locator("[data-investigation-chart]")).toHaveCount(0);
|
||||
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(1);
|
||||
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(0);
|
||||
|
||||
for (const [label, error] of [
|
||||
["错误:JSON 语法", "第 2 行不是合法 JSON"],
|
||||
@@ -212,7 +207,7 @@ test("JSONL cases switch directly, reset validation and remove the previous loca
|
||||
await execute.click();
|
||||
await expect(tools.getByRole("alert")).toContainText(error);
|
||||
await expect(preview).toHaveCount(0);
|
||||
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(1);
|
||||
await expect(tools.getByRole("list", { name: "动作识别记录" })).toHaveCount(0);
|
||||
}
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
@@ -228,14 +223,14 @@ test("JSONL cases preview tables, charts and map overlays without an investigati
|
||||
await expect(cases).toBeEnabled();
|
||||
await cases.click();
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
await expect(page.getByRole("option")).toHaveCount(12);
|
||||
await expect(page.getByRole("option")).toHaveCount(14);
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
await testInfo.attach("case-dropdown-without-task", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("dropdown-without-task.png") }), contentType: "image/png"
|
||||
});
|
||||
await page.getByRole("option", { name: "候选管段对比", exact: true }).click();
|
||||
await expect(cases).toContainText("候选管段对比");
|
||||
await expect(tools.getByLabel("调查展示 JSONL", { exact: true })).toHaveValue(/DEMO-PIPE-01/);
|
||||
await expect(tools.getByLabel("调查展示 JSONL", { exact: true })).toHaveValue(new RegExp(INVESTIGATION_DEV_NETWORK.pipeIds[0]));
|
||||
const execute = tools.getByRole("button", { name: "执行本地预览", exact: true });
|
||||
await expect(execute).toBeEnabled();
|
||||
await execute.click();
|
||||
@@ -310,6 +305,7 @@ test("presentation panel keeps its header fixed while map controls, chart source
|
||||
|
||||
for (const [name, width, height] of [["desktop", 1440, 900], ["compact", 1024, 640], ["mobile", 375, 812]] as const) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect.poll(async () => Math.abs((await panel.boundingBox())!.height - Math.min(480, height * 0.55))).toBeLessThan(1);
|
||||
await chart.getByRole("img", { name: "本地合成压力趋势", exact: true }).scrollIntoViewIfNeeded();
|
||||
expect(await panel.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
@@ -320,6 +316,17 @@ test("presentation panel keeps its header fixed while map controls, chart source
|
||||
await testInfo.attach(`presentation-${name}`, {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath(`presentation-${name}.png`) }), contentType: "image/png"
|
||||
});
|
||||
await panel.getByRole("button", { name: "收起展示", exact: true }).click();
|
||||
await expect(panel).toHaveAttribute("data-investigation-collapsed", "true");
|
||||
await expect(chart).toHaveCount(0);
|
||||
expect((await panel.boundingBox())!.height).toBe(112);
|
||||
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toBeVisible();
|
||||
expect(await header.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeLessThanOrEqual(1);
|
||||
await testInfo.attach(`presentation-${name}-collapsed`, {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath(`presentation-${name}-collapsed.png`) }), contentType: "image/png"
|
||||
});
|
||||
await panel.getByRole("button", { name: "展开展示", exact: true }).click();
|
||||
await expect(chart).toBeVisible();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await panel.getByRole("button", { name: "收起展示", exact: true }).click();
|
||||
@@ -330,9 +337,305 @@ test("presentation panel keeps its header fixed while map controls, chart source
|
||||
await chart.getByRole("button", { name: "关闭图表 本地合成压力趋势", exact: true }).click();
|
||||
await expect(chart).toHaveCount(0);
|
||||
await expect(overlay).toBeVisible();
|
||||
expect((await panel.boundingBox())!.height).toBe(480);
|
||||
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
|
||||
await page.getByRole("option", { name: "纯 JSONL 数据", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
expect((await panel.boundingBox())!.height).toBe(480);
|
||||
await tools.getByRole("button", { name: "清除临时展示", exact: true }).click();
|
||||
expect((await panel.boundingBox())!.height).toBe(480);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("individual charts float, move and resize while the preview stays in place", async ({ page }, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
const mock = await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByRole("button", { name: "载入合成样例", exact: true }).click();
|
||||
const input = tools.getByLabel("调查展示 JSONL", { exact: true });
|
||||
await input.fill(`${await input.inputValue()}\n${JSON.stringify({ record_type: "presentation", command: {
|
||||
action: "open_chart", chart_id: "second-chart", evidence_refs: ["dev:synthetic"],
|
||||
chart: { title: "合成流量趋势", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "流量", data: [10, 12] }] }
|
||||
} })}`);
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
const original = (await panel.boundingBox())!;
|
||||
const chart = panel.locator('[data-investigation-chart="dev-synthetic-pressure"]');
|
||||
const plot = chart.getByRole("img", { name: "本地合成压力趋势", exact: true });
|
||||
await plot.evaluate((element) => element.setAttribute("data-preview-state", "preserved"));
|
||||
const trigger = chart.getByRole("button", { name: "放大查看 本地合成压力趋势", exact: true });
|
||||
await trigger.click();
|
||||
const first = page.getByRole("dialog", { name: "本地合成压力趋势", exact: true });
|
||||
await expect(first).toBeVisible();
|
||||
await expect(plot).toHaveAttribute("data-preview-state", "preserved");
|
||||
expect(await panel.boundingBox()).toEqual(original);
|
||||
const firstPlot = first.getByRole("img", { name: "本地合成压力趋势", exact: true });
|
||||
expect((await firstPlot.boundingBox())!.width).toBeGreaterThan((await plot.boundingBox())!.width);
|
||||
const before = (await first.boundingBox())!;
|
||||
const heading = first.locator(".floating-window-heading");
|
||||
const bar = (await heading.boundingBox())!;
|
||||
await page.mouse.move(bar.x + 90, bar.y + 20);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bar.x + 190, bar.y + 90, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await first.boundingBox())!.x - before.x).toBeGreaterThan(90);
|
||||
await expect.poll(async () => (await first.boundingBox())!.y - before.y).toBeGreaterThan(60);
|
||||
const beforeResize = (await first.boundingBox())!;
|
||||
const handle = (await first.locator(".floating-window-resize").boundingBox())!;
|
||||
await page.mouse.move(handle.x + 8, handle.y + 8);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x + 88, handle.y + 58, { steps: 8 });
|
||||
const resizedPlot = firstPlot.locator("svg");
|
||||
await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("width")) - (await firstPlot.boundingBox())!.width)).toBeLessThan(1);
|
||||
await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("height")) - (await firstPlot.boundingBox())!.height)).toBeLessThan(1);
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await first.boundingBox())!.width - beforeResize.width).toBeGreaterThan(70);
|
||||
await expect.poll(async () => (await first.boundingBox())!.height - beforeResize.height).toBeGreaterThan(40);
|
||||
expect(await panel.boundingBox()).toEqual(original);
|
||||
await testInfo.attach("chart-floating", { body: await page.screenshot({ path: testInfo.outputPath("chart-floating.png") }), contentType: "image/png" });
|
||||
|
||||
await panel.locator('[data-investigation-chart="second-chart"]').getByRole("button", { name: "放大查看 合成流量趋势", exact: true }).click();
|
||||
const second = page.getByRole("dialog", { name: "合成流量趋势", exact: true });
|
||||
await expect(second).toBeVisible();
|
||||
await expect(first).toBeVisible();
|
||||
await heading.focus();
|
||||
expect(Number(await first.evaluate((element) => getComputedStyle(element).zIndex))).toBeGreaterThan(Number(await second.evaluate((element) => getComputedStyle(element).zIndex)));
|
||||
const moved = (await first.boundingBox())!;
|
||||
await page.keyboard.press("ArrowLeft");
|
||||
expect((await first.boundingBox())!.x).toBe(moved.x - 16);
|
||||
await page.keyboard.press("Shift+ArrowRight");
|
||||
expect((await first.boundingBox())!.width).toBe(moved.width + 16);
|
||||
await first.getByRole("button", { name: "最大化窗口", exact: true }).click();
|
||||
await expect.poll(async () => (await first.boundingBox())!.width).toBe(1424);
|
||||
await first.getByRole("button", { name: "还原窗口", exact: true }).click();
|
||||
expect((await first.boundingBox())!.width).toBe(moved.width + 16);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(first).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await expect(second).toBeVisible();
|
||||
await second.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await expect(second).toHaveCount(0);
|
||||
|
||||
await trigger.click();
|
||||
for (const [name, width, height] of [["compact", 1024, 640], ["mobile", 375, 812], ["small", 320, 640]] as const) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect.poll(async () => (await first.boundingBox())!.x + (await first.boundingBox())!.width).toBeLessThanOrEqual(width);
|
||||
const box = (await first.boundingBox())!;
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(height);
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(await first.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await testInfo.attach(`chart-floating-${name}`, { body: await first.screenshot({ path: testInfo.outputPath(`chart-floating-${name}.png`) }), contentType: "image/png" });
|
||||
}
|
||||
await heading.focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(first).toHaveCount(0);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("report chart blocks enlarge independently and keep range controls usable", async ({ page }) => {
|
||||
const mock = await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({ record_type: "ui_block", evidence_refs: [], block: {
|
||||
version: "answer-ui@1", block_id: "floating-pressure", phase: "final", fallback_text: "合成压力数据", spec: {
|
||||
root: "chart", elements: { chart: { type: "Chart", props: { title: "交互压力图", chart_type: "line", source: "合成样例", x_data: ["08:00", "08:15"], series: [{ name: "压力", data: [31, 29] }] }, children: [] } }
|
||||
}
|
||||
} }));
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
await expect(panel).toHaveCSS("transform", "none");
|
||||
const bounds = (await panel.boundingBox())!;
|
||||
const trigger = panel.getByRole("button", { name: "放大查看 交互压力图", exact: true });
|
||||
await trigger.click();
|
||||
const floating = page.getByRole("dialog", { name: "交互压力图", exact: true });
|
||||
await expect(floating).toBeVisible();
|
||||
await floating.getByRole("button", { name: "筛选数据范围", exact: true }).click();
|
||||
await floating.getByRole("combobox", { name: "区间起点", exact: true }).click();
|
||||
await expect(page.getByRole("option", { name: "08:15", exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("listbox")).toHaveCount(0);
|
||||
await expect(floating).toBeVisible();
|
||||
await floating.getByRole("combobox", { name: "区间起点", exact: true }).click();
|
||||
await page.getByRole("option", { name: "08:15", exact: true }).click();
|
||||
await floating.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(floating.getByRole("table")).toContainText("29");
|
||||
await floating.getByRole("button", { name: "放大查看表格 交互压力图", exact: true }).click();
|
||||
const dataWindow = page.getByRole("dialog", { name: "交互压力图 · 表格", exact: true });
|
||||
await expect(dataWindow.getByRole("table")).toContainText("29");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dataWindow).toHaveCount(0);
|
||||
await expect(floating).toBeVisible();
|
||||
const after = (await panel.boundingBox())!;
|
||||
for (const key of ["x", "y", "width", "height"] as const) expect(Math.abs(after[key] - bounds[key])).toBeLessThan(1);
|
||||
await floating.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("complex tables retain search and sort in floating windows, including empty and raw results", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const mock = await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
const cases = tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true });
|
||||
async function choose(label: string) {
|
||||
await cases.click();
|
||||
await page.getByRole("option", { name: label, exact: true }).click();
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
}
|
||||
await choose("复杂表格:宽表、长文本与边界数据");
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
await expect(panel).toHaveCSS("transform", "none");
|
||||
const before = (await panel.boundingBox())!;
|
||||
const trigger = panel.getByRole("button", { name: "放大查看表格 合成候选核查宽表", exact: true });
|
||||
await panel.getByRole("textbox", { name: "搜索合成候选核查宽表", exact: true }).fill("样例记录 042");
|
||||
await trigger.click();
|
||||
const wide = page.getByRole("dialog", { name: "合成候选核查宽表 · 表格", exact: true });
|
||||
const search = wide.getByRole("textbox", { name: "搜索合成候选核查宽表", exact: true });
|
||||
await expect(search).toHaveValue("样例记录 042");
|
||||
await expect(wide.getByText("1 / 50 行", { exact: true })).toBeVisible();
|
||||
await search.fill("");
|
||||
await wide.getByRole("button", { name: "按序号排序", exact: true }).click();
|
||||
await wide.getByRole("button", { name: "按序号排序", exact: true }).click();
|
||||
await expect(wide.locator("tbody tr").first()).toContainText(INVESTIGATION_DEV_NETWORK.pipeIds[49]);
|
||||
await search.fill("不存在的合成对象");
|
||||
await expect(wide).toContainText("当前筛选没有匹配的数据");
|
||||
await search.fill(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
|
||||
const after = (await panel.boundingBox())!;
|
||||
for (const key of ["x", "y", "width", "height"] as const) expect(Math.abs(after[key] - before[key])).toBeLessThan(1);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await expect.poll(async () => (await wide.boundingBox())!.width).toBeLessThanOrEqual(359);
|
||||
expect(await wide.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const region = wide.getByRole("region", { name: "合成候选核查宽表", exact: true });
|
||||
expect(await region.evaluate((element) => element.scrollWidth)).toBeGreaterThan(await region.evaluate((element) => element.clientWidth));
|
||||
await testInfo.attach("wide-table-mobile", { body: await wide.screenshot({ path: testInfo.outputPath("wide-table-mobile.png") }), contentType: "image/png" });
|
||||
await wide.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await expect(trigger).toBeFocused();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await panel.getByRole("button", { name: "放大查看表格 合成空结果表", exact: true }).click();
|
||||
const empty = page.getByRole("dialog", { name: "合成空结果表 · 表格", exact: true });
|
||||
await expect(empty).toContainText("没有已加载的数据");
|
||||
await empty.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await choose("纯 JSONL 数据");
|
||||
await panel.getByRole("button", { name: "放大查看表格 调查数据摘要", exact: true }).click();
|
||||
const raw = page.getByRole("dialog", { name: "调查数据摘要", exact: true });
|
||||
await expect(raw.getByRole("table")).toContainText(INVESTIGATION_DEV_NETWORK.pipeIds[2]);
|
||||
await expect(raw.locator("tbody tr")).toHaveCount(2);
|
||||
await raw.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("complex investigation cases show multi-chart and multi-table results without feature lookup actions", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const mock = await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
|
||||
await expect(page.getByRole("option", { name: "地图定位与高亮", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成:2 条展示指令、4 个界面块");
|
||||
const panel = page.getByLabel("本地预览展示", { exact: true });
|
||||
await expect(panel.getByRole("button", { name: "放大查看 合成全天压力对比", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "放大查看 合成分区流量趋势", exact: true })).toHaveCount(1);
|
||||
await expect(panel.getByRole("button", { name: "放大查看表格 合成候选方案核查", exact: true })).toHaveCount(1);
|
||||
await expect(panel.getByRole("button", { name: "放大查看表格 合成阀门核查清单", exact: true })).toHaveCount(1);
|
||||
await page.getByTestId("map-dev-panel").getByRole("tab", { name: "地图调试", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "仅高亮", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "缩放到要素", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "定位并高亮", exact: true })).toBeVisible();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("expanded bars fill the window while preserving legends, type size and the chart instance", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
|
||||
await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成候选方案影响对比", exact: true }).click();
|
||||
const floating = page.getByRole("dialog", { name: "合成候选方案影响对比", exact: true });
|
||||
const legend = floating.getByLabel("类别图例", { exact: true });
|
||||
for (const label of ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"]) {
|
||||
await expect(legend.getByText(label, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(legend).toContainText("610");
|
||||
const plot = floating.getByRole("img", { name: "合成候选方案影响对比", exact: true });
|
||||
await plot.scrollIntoViewIfNeeded();
|
||||
const svg = plot.locator("svg");
|
||||
await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width);
|
||||
const drawing = (await plot.boundingBox())!;
|
||||
const sidebar = (await floating.locator("aside").boundingBox())!;
|
||||
const instance = await plot.locator(".echarts-for-react").getAttribute("_echarts_instance_");
|
||||
const fontSize = await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize);
|
||||
await floating.locator(".floating-window-heading").focus();
|
||||
await page.keyboard.press("Shift+ArrowRight");
|
||||
await page.keyboard.press("Shift+ArrowDown");
|
||||
await expect.poll(async () => (await plot.boundingBox())!.width).toBe(drawing.width + 16);
|
||||
await expect.poll(async () => (await plot.boundingBox())!.height).toBe(drawing.height + 16);
|
||||
await floating.getByRole("button", { name: "最大化窗口", exact: true }).click();
|
||||
await expect.poll(async () => (await plot.boundingBox())!.width).toBeGreaterThan(drawing.width + 400);
|
||||
await expect.poll(async () => (await plot.boundingBox())!.height).toBeGreaterThan(drawing.height + 100);
|
||||
await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width);
|
||||
await expect.poll(async () => Number(await svg.getAttribute("height"))).toBe((await plot.boundingBox())!.height);
|
||||
expect((await floating.locator("aside").boundingBox())!.width).toBe(sidebar.width);
|
||||
expect(await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize)).toBe(fontSize);
|
||||
await expect(plot.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
await testInfo.attach("responsive-bar-maximized", { body: await floating.screenshot({ path: testInfo.outputPath("responsive-bar-maximized.png") }), contentType: "image/png" });
|
||||
await floating.getByRole("button", { name: "还原窗口", exact: true }).click();
|
||||
await expect.poll(async () => (await plot.boundingBox())!.width).toBe(drawing.width + 16);
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
const viewport = floating.locator("[data-floating-window-content]");
|
||||
await expect.poll(async () => (await floating.boundingBox())!.width).toBeLessThanOrEqual(359);
|
||||
expect((await plot.boundingBox())!.width).toBeLessThan(drawing.width);
|
||||
await viewport.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
expect(await viewport.evaluate((element) => element.scrollLeft)).toBe(0);
|
||||
await floating.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(floating.getByRole("table")).toContainText("合成方案 E:待补充资料");
|
||||
});
|
||||
|
||||
test("full-day charts expose all eight series and retain legend selection while resizing", async ({ page }) => {
|
||||
await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const tools = page.getByTestId("investigation-dev-tools");
|
||||
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
|
||||
await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click();
|
||||
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
|
||||
await expect(tools.getByRole("status")).toContainText("本地预览完成");
|
||||
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成全天压力对比", exact: true }).click();
|
||||
const floating = page.getByRole("dialog", { name: "合成全天压力对比", exact: true });
|
||||
const series = floating.getByLabel("显示的数据序列").getByRole("button");
|
||||
await expect(series).toHaveCount(8);
|
||||
await series.last().click();
|
||||
await expect(series.last()).toHaveAttribute("aria-pressed", "false");
|
||||
const plot = floating.getByRole("img", { name: "合成全天压力对比", exact: true });
|
||||
const drawing = (await plot.boundingBox())!;
|
||||
await floating.locator(".floating-window-heading").focus();
|
||||
await page.keyboard.press("Shift+ArrowDown");
|
||||
await expect.poll(async () => (await plot.boundingBox())!.height).toBe(drawing.height + 16);
|
||||
await expect(series.last()).toHaveAttribute("aria-pressed", "false");
|
||||
await floating.getByRole("button", { name: "显示全部序列", exact: true }).click();
|
||||
await expect(series.last()).toHaveAttribute("aria-pressed", "true");
|
||||
await floating.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(floating.locator("tbody tr")).toHaveCount(96);
|
||||
});
|
||||
|
||||
for (const reducedMotion of ["no-preference", "reduce"] as const) {
|
||||
test(`presentation actions remain interruptible with ${reducedMotion} motion`, async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
@@ -51,7 +51,7 @@ test.beforeEach(async ({ page }) => {
|
||||
task = { ...task, latest_report: { ...report, revision: 2, report: { ...report.report,
|
||||
current_conclusion: "上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。",
|
||||
conclusion_boundary: "覆盖2026-12-31 15:30–17:00Z。",
|
||||
recommendations: ["复核2026-10-08T05:00:00Z的数据。"],
|
||||
recommendations: ["复核2026-10-08T05:00:00Z的数据。", "目标2026-07-16 00:00(UTC0),当前08:00(UTC+8)。"],
|
||||
data_gaps: [{ ...report.report.data_gaps[0], scope: "2026-10-08 04:00–05:00Z" }]
|
||||
} } };
|
||||
Object.assign(window, { storedReportConclusion: task.latest_report.report.current_conclusion });
|
||||
@@ -112,7 +112,7 @@ for (const clearProgressDiagram of [false, true]) {
|
||||
test.describe("报告正文时间展示", () => {
|
||||
test.use({ timezoneId: "UTC" });
|
||||
|
||||
test("历史 UTC 正文和查询范围按 UTC+8 展示并保留原始报告", async ({ page }) => {
|
||||
test("历史 UTC 正文和查询范围按 UTC+8 展示并保留原始报告", async ({ page }, testInfo) => {
|
||||
await page.evaluate(() => (window as unknown as { showHistoricalUtcReport: () => void }).showHistoricalUtcReport());
|
||||
const report = page.getByRole("region", { name: "任务报告", exact: true });
|
||||
await expect(report).toContainText("上报时间2026/10/8 12:00:00(UTC+8),查询12:00–13:00(UTC+8)。");
|
||||
@@ -121,8 +121,10 @@ test.describe("报告正文时间展示", () => {
|
||||
await page.getByRole("button", { name: "展开当前结果", exact: true }).click();
|
||||
const detail = page.getByRole("dialog", { name: "当前结果", exact: true });
|
||||
await expect(detail).toContainText("复核2026/10/8 13:00:00(UTC+8)的数据。");
|
||||
await expect(detail).toContainText("目标2026/7/16 08:00:00(UTC+8),当前08:00(UTC+8)。");
|
||||
expect(await page.evaluate(() => (window as unknown as { storedReportConclusion: string }).storedReportConclusion))
|
||||
.toBe("上报时间2026-10-08T04:00:00Z,查询04:00–05:00Z。");
|
||||
await testInfo.attach("utc8-report", { body: await detail.screenshot({ path: testInfo.outputPath("utc8-report.png"), animations: "disabled" }), contentType: "image/png" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { MAP_STYLE_TOKENS } from "../../src/features/workbench/map/map-colors";
|
||||
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
|
||||
|
||||
const chart = (index: number) => ({ action: "open_chart", chart_id: "pressure-history", evidence_refs: ["synthetic:pressure"], chart: {
|
||||
title: `压力对比 ${index}`, chart_type: "line", x_data: ["10:00", "10:15"], series: [{ name: "压力(m)", data: [30, 30 - index] }]
|
||||
} });
|
||||
const overlay = { action: "set_overlay", overlay_id: "warning-area", title: "合成影响示意", mode: "geometry", evidence_id: "synthetic:area", data: {
|
||||
type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[121.48,31.1],[121.49,31.1],[121.49,31.11],[121.48,31.1]]] } }]
|
||||
} };
|
||||
|
||||
test("successful charts and warning geometry survive stream loss and failed reconnect", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const task = investigationFixture(1, "断线保留展示核查");
|
||||
const other = investigationFixture(2, "切换后独立调查");
|
||||
const mock = await mockInvestigationPresentation(page, [task, other]);
|
||||
await selectInvestigation(page, task.title);
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
|
||||
await mock.emit(task, chart(1));
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
await mock.emit(task, overlay);
|
||||
await expect(panel.getByRole("region", { name: "调查覆盖层" })).toContainText("合成影响示意");
|
||||
const geometry = () => page.evaluate(async () => {
|
||||
const source = window.__waterNetworkMap?.getSource("investigation-presentation-overlay") as { getData: () => Promise<unknown> } | undefined;
|
||||
return source?.getData();
|
||||
});
|
||||
const saved = await geometry();
|
||||
expect(await page.evaluate(() => window.__waterNetworkMap?.getPaintProperty("investigation-presentation-overlay-fill", "fill-color"))).toBe(MAP_STYLE_TOKENS.state.risk);
|
||||
let failCursor = true;
|
||||
await page.route(`**/operational-tasks/${task.task_id}/presentation-cursor`, (route) => failCursor ? route.abort("failed") : route.fallback());
|
||||
const connection = (available: boolean) => page.evaluate((available) => {
|
||||
(window as unknown as { setInvestigationTestConnection: (available: boolean) => void }).setInvestigationTestConnection(available);
|
||||
}, available);
|
||||
await connection(false);
|
||||
await expect(panel.getByText("连接中断", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
expect(await geometry()).toEqual(saved);
|
||||
await connection(true);
|
||||
await expect(panel.getByText("部分未完成", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
expect(await geometry()).toEqual(saved);
|
||||
await testInfo.attach("results-retained-offline", { body: await page.screenshot({ path: testInfo.outputPath("results-retained-offline.png") }), contentType: "image/png" });
|
||||
failCursor = false;
|
||||
await connection(false);
|
||||
await expect(panel.getByText("连接中断", { exact: true })).toBeVisible();
|
||||
await connection(true);
|
||||
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
|
||||
expect(await geometry()).toEqual(saved);
|
||||
await page.getByRole("region", { name: "工况任务", exact: true }).getByText(other.title, { exact: true }).first().click();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toHaveCount(0);
|
||||
await expect.poll(geometry).toEqual({ type: "FeatureCollection", features: [] });
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("history playback pauses, seeks and finishes on a frozen snapshot while current receives new results", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const task = investigationFixture(1, "只读历史播放核查");
|
||||
const saved = [1, 2, 3].map((index) => ({ task_id: task.task_id, event_seq: index, run_id: task.active_run!.run_id,
|
||||
execution_generation: 1, occurred_at: task.created_at, command: chart(index) }));
|
||||
const mock = await mockInvestigationPresentation(page, [task], saved);
|
||||
await selectInvestigation(page, task.title);
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toBeVisible();
|
||||
await page.getByRole("dialog", { name: "压力对比 3", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "播放历史", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "暂停历史播放", exact: true }).click();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
await page.getByRole("dialog", { name: "压力对比 1", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await panel.getByRole("combobox", { name: "历史播放速度" }).click();
|
||||
await page.getByRole("option", { name: "2×", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "播放历史", exact: true }).click();
|
||||
await mock.emit(task, chart(4));
|
||||
await expect(panel.getByRole("group", { name: "压力对比 3", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "播放历史", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toHaveCount(0);
|
||||
await panel.getByRole("slider", { name: "历史播放进度" }).focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
await testInfo.attach("history-playback-desktop", { body: await panel.screenshot({ path: testInfo.outputPath("history-playback-desktop.png") }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 375, height: 720 });
|
||||
await expect(panel.getByRole("slider", { name: "历史播放进度" })).toBeVisible();
|
||||
expect(await panel.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
|
||||
await testInfo.attach("history-playback-narrow", { body: await panel.screenshot({ path: testInfo.outputPath("history-playback-narrow.png") }), contentType: "image/png" });
|
||||
await page.getByRole("dialog", { name: "压力对比 1", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "回到当前", exact: true }).click();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 4", exact: true })).toBeVisible();
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
|
||||
test("restores a persisted chart when the stream disconnects during display confirmation", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const task = investigationFixture(1, "图表确认阶段断线恢复");
|
||||
const mock = await mockInvestigationPresentation(page, [task]);
|
||||
await selectInvestigation(page, task.title);
|
||||
const panel = page.getByLabel("调查实时展示", { exact: true });
|
||||
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
|
||||
await mock.emit(task, chart(1));
|
||||
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("1. ");
|
||||
await page.evaluate(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const plot = document.querySelector('[data-presentation-scope="investigation"] [data-investigation-chart="pressure-history"]');
|
||||
if (!plot?.textContent?.includes("压力对比 2")) return;
|
||||
observer.disconnect();
|
||||
Object.assign(window, { interruptedChartConfirmation: true });
|
||||
(window as unknown as { setInvestigationTestConnection: (available: boolean) => void }).setInvestigationTestConnection(false);
|
||||
});
|
||||
observer.observe(document.body, { childList: true, characterData: true, subtree: true });
|
||||
});
|
||||
await mock.emit(task, chart(2));
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { interruptedChartConfirmation?: boolean }).interruptedChartConfirmation)).toBe(true);
|
||||
await expect(panel.getByText("连接中断", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 1", exact: true })).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { setInvestigationTestConnection: (available: boolean) => void }).setInvestigationTestConnection(true);
|
||||
});
|
||||
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("group", { name: "压力对比 2", exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2 个动作");
|
||||
await testInfo.attach("chart-recovered-after-interrupted-confirmation", {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath("chart-recovered-after-interrupted-confirmation.png") }), contentType: "image/png",
|
||||
});
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
for (const width of [1440, 1024]) {
|
||||
test(`batch feature location highlights all matches and reports missing IDs at ${width}px`, async ({ page }) => {
|
||||
for (const { width, entry } of [
|
||||
{ width: 1440, entry: "dev" }, { width: 1024, entry: "dev" },
|
||||
{ width: 1440, entry: "tool" }, { width: 375, entry: "tool" }, { width: 320, entry: "tool" }
|
||||
]) {
|
||||
test(`batch feature location highlights all matches and reports missing IDs through ${entry} at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await mockInvestigationPresentation(page, []);
|
||||
const queries: string[] = [];
|
||||
@@ -18,9 +21,10 @@ for (const width of [1440, 1024]) {
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
await panel.getByRole("tab", { name: "地图调试", exact: true }).click();
|
||||
const trigger = page.getByRole("button", { name: entry === "dev" ? "切换地图 Dev Panel" : "定位:查询地址或管网要素" });
|
||||
await trigger.click();
|
||||
const panel = entry === "dev" ? page.getByTestId("map-dev-panel") : page.getByRole("region", { name: "定位", exact: true });
|
||||
await panel.getByRole("tab", { name: entry === "dev" ? "地图调试" : "管网要素", exact: true }).click();
|
||||
const ids = panel.getByRole("textbox", { name: "要素 ID(可多个)" });
|
||||
await ids.fill("P-1,P-2\nP-1 missing");
|
||||
await panel.getByRole("button", { name: "定位并高亮", exact: true }).click();
|
||||
@@ -35,7 +39,14 @@ for (const width of [1440, 1024]) {
|
||||
return bounds?.contains([121.4, 31.05]) && bounds.contains([121.52, 31.14]);
|
||||
})).toBe(true);
|
||||
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((element) => element.clientWidth));
|
||||
await page.screenshot({ path: `/tmp/map-multi-feature-location-${width}.png` });
|
||||
if (entry === "tool") {
|
||||
const heading = panel.getByRole("heading", { name: "定位", exact: true });
|
||||
expect(await heading.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2) === element;
|
||||
})).toBe(true);
|
||||
}
|
||||
await page.screenshot({ path: `/tmp/map-multi-feature-location-${entry}-${width}.png` });
|
||||
|
||||
await panel.getByRole("button", { name: "清除高亮", exact: true }).click();
|
||||
await expect.poll(highlights).toEqual([false, false]);
|
||||
@@ -55,7 +66,8 @@ for (const width of [1440, 1024]) {
|
||||
await expect.poll(highlights).toEqual([false, false]);
|
||||
await ids.fill(" ");
|
||||
await expect(panel.getByRole("button", { name: "定位并高亮", exact: true })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
if (entry === "dev") await page.keyboard.press("Escape");
|
||||
else await trigger.click();
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
await page.getByTestId("map-dev-panel").getByRole("tab", { name: "通知测试" }).click();
|
||||
});
|
||||
|
||||
test("Dev previews all five tones, transient expiry and loading completion", async ({ page }, testInfo) => {
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
for (const label of ["信息", "成功", "警告", "错误", "加载"]) {
|
||||
await panel.getByRole("button", { name: `测试${label}通知` }).click();
|
||||
}
|
||||
const loading = page.locator("[data-sonner-toast]").filter({ hasText: "正在加载管网 · 测试" });
|
||||
await expect(loading).toBeVisible();
|
||||
await expect(loading.getByRole("button", { name: "关闭通知" })).toHaveCount(0);
|
||||
await expect(loading).toHaveCSS("opacity", "1");
|
||||
await testInfo.attach("notice-tones", { body: await page.screenshot({ path: testInfo.outputPath("tones.png") }), contentType: "image/png" });
|
||||
await panel.getByRole("button", { name: "模拟加载完成" }).click();
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "管网加载完成 · 测试" })).toBeVisible();
|
||||
await page.mouse.move(5, 5);
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "管网加载完成 · 测试" })).toHaveCount(0, { timeout: 6000 });
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "地图视图已更新 · 测试" })).toHaveCount(0);
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "部分监测数据延迟 · 测试" })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "清除测试通知" }).click();
|
||||
await expect(page.locator("[data-sonner-toast]").filter({ hasText: "· 测试" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const width of [1280, 375, 320]) {
|
||||
test(`linked notices survive timeout and navigation, wrap and close independently at ${width}px`, async ({ page }, testInfo) => {
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
await panel.getByRole("button", { name: "测试报告通知" }).click();
|
||||
await panel.getByRole("button", { name: "测试任务通知" }).click();
|
||||
await panel.getByRole("button", { name: "测试长文本通知" }).click();
|
||||
const notices = page.locator("[data-sonner-toast]").filter({ hasText: "保留至手动关闭" });
|
||||
await expect(notices).toHaveCount(3);
|
||||
await notices.filter({ hasText: "新收到一项" }).getByRole("button", { name: "查看任务" }).click();
|
||||
await expect(panel.getByRole("status")).toHaveText("已触发“查看任务”测试操作,通知仍保留。");
|
||||
await notices.filter({ hasText: "城南片区" }).getByRole("button", { name: "查看报告" }).click();
|
||||
await expect(panel.getByRole("status")).toHaveText("已触发“查看报告”测试操作,通知仍保留。");
|
||||
await page.getByRole("button", { name: "关闭 Dev Panel" }).click();
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.mouse.move(5, 5);
|
||||
await page.clock.install();
|
||||
await page.clock.fastForward(15_000);
|
||||
await expect(notices).toHaveCount(3);
|
||||
for (const notice of await notices.all()) {
|
||||
await expect(notice).toBeInViewport();
|
||||
expect(await notice.evaluate((element) => ({
|
||||
overflow: element.scrollWidth > element.clientWidth,
|
||||
left: element.getBoundingClientRect().left,
|
||||
right: element.getBoundingClientRect().right <= innerWidth
|
||||
}))).toEqual({ overflow: false, left: expect.any(Number), right: true });
|
||||
const close = notice.getByRole("button", { name: "关闭通知" });
|
||||
await expect.poll(() => close.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
|
||||
})).toBe(true);
|
||||
}
|
||||
await testInfo.attach(`linked-notices-${width}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("notices.png") }), contentType: "image/png"
|
||||
});
|
||||
await notices.filter({ hasText: "新收到一项" }).getByRole("button", { name: "关闭通知" }).click();
|
||||
await page.clock.runFor(250);
|
||||
await expect(notices).toHaveCount(2);
|
||||
await notices.filter({ hasText: "城南片区" }).getByRole("button", { name: "查看报告" }).click();
|
||||
await expect(notices).toHaveCount(2);
|
||||
});
|
||||
}
|
||||
|
||||
test("closing retained notices reveals the remaining notices in the original stack", async ({ page }, testInfo) => {
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
await panel.getByRole("button", { name: "连续 6 条通知" }).click();
|
||||
const notices = page.locator("[data-sonner-toast]").filter({ hasText: "保留至手动关闭" });
|
||||
await expect(notices).toHaveCount(6);
|
||||
const oldest = notices.filter({ hasText: "第 1 条测试通知" });
|
||||
await expect(oldest).toHaveAttribute("data-visible", "false");
|
||||
await notices.first().getByRole("button", { name: "关闭通知" }).click();
|
||||
await expect(notices).toHaveCount(5);
|
||||
await notices.first().getByRole("button", { name: "关闭通知" }).click();
|
||||
await expect(notices).toHaveCount(4);
|
||||
await expect(oldest).toHaveAttribute("data-visible", "true");
|
||||
await testInfo.attach("notice-original-stack", { body: await page.screenshot({ path: testInfo.outputPath("stack.png") }), contentType: "image/png" });
|
||||
await panel.getByRole("button", { name: "清除测试通知" }).click();
|
||||
await expect(notices).toHaveCount(0);
|
||||
});
|
||||
@@ -109,19 +109,19 @@ test.describe("外围系统", () => {
|
||||
const appointmentList = panel.getByLabel("预约工单列表");
|
||||
const complaintList = panel.getByLabel("投诉工单列表");
|
||||
const burstList = panel.getByLabel("爆管上报工单列表");
|
||||
await expect(appointmentList).toContainText("合成通信核查");
|
||||
await expect(appointmentList).toContainText("通信核查");
|
||||
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
|
||||
await testInfo.attach("peripheral-work-orders", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("work-orders.png") }), contentType: "image/png"
|
||||
});
|
||||
await expect(appointmentList).toContainText("合成压力偏差复核");
|
||||
await expect(appointmentList).toContainText("合成零流量样本确认");
|
||||
await expect(appointmentList).toContainText("压力偏差复核");
|
||||
await expect(appointmentList).toContainText("零流量样本确认");
|
||||
await expect(appointmentList).toContainText("待处理");
|
||||
await expect(appointmentList).toContainText("处理中");
|
||||
await expect(appointmentList).toContainText("已完成");
|
||||
await expect(appointmentList.getByText("工单报告", { exact: true })).toHaveCount(3);
|
||||
await expect(appointmentList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
|
||||
const appointmentDetails = appointmentList.getByLabel("工单详情 合成通信核查", { exact: true });
|
||||
const appointmentDetails = appointmentList.getByLabel("工单详情 通信核查", { exact: true });
|
||||
await expect(appointmentDetails.getByText("工单报告", { exact: true })).toBeHidden();
|
||||
await appointmentDetails.locator(":scope > summary").click();
|
||||
await expect(appointmentDetails.getByText("工单报告", { exact: true })).toBeVisible();
|
||||
@@ -163,6 +163,7 @@ test.describe("外围系统", () => {
|
||||
});
|
||||
|
||||
test("SCADA 调频时间轴按周期主动监测并隐藏设备列表", async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const latestRequests: string[] = [];
|
||||
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
|
||||
await page.route("**/runtime-config.js", (route) =>
|
||||
@@ -225,11 +226,25 @@ test.describe("外围系统", () => {
|
||||
});
|
||||
|
||||
const replayRequests: string[] = [];
|
||||
const selectedMonitoringTimes: string[] = [];
|
||||
const monitoringConfig = {
|
||||
rule_version: "burst-monitor-v2", decision_policy: "weighted",
|
||||
pressure_device_ids: ["PRESSURE-17"], outlet_flow_device_ids: ["FLOW-09"],
|
||||
weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 }, alert_threshold: 0.8
|
||||
};
|
||||
let replay = {
|
||||
replay_id: "60000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1,
|
||||
status: "paused", start_time: "2026-10-22T00:00:00Z", end_time: "2026-10-22T06:00:00Z",
|
||||
virtual_now: "2026-10-22T01:02:03Z", delivered_until: "2026-10-22T01:02:03Z", processed_until: "2026-10-22T01:02:03Z",
|
||||
monitoring_interval_seconds: 900, step_pending: false,
|
||||
monitoring_config: monitoringConfig,
|
||||
monitoring_calibration: {
|
||||
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
|
||||
status: "ready", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: "2026-10-22T00:00:00Z",
|
||||
burst_detection: { threshold: -0.0843 },
|
||||
pressure: { "PRESSURE-17": { threshold_percent: 6.5 } },
|
||||
outlet_flow: { "FLOW-09": { threshold_percent: 8.5 } }
|
||||
},
|
||||
monitoring_count: 0, last_monitoring: null as null | Record<string, unknown>,
|
||||
processed_count: 8, anomaly_count: 0, rules: [{
|
||||
rule_id: "pressure_drop", device_ids: ["PRESSURE-17"], kind: "relative_change", direction: "decrease",
|
||||
@@ -241,27 +256,64 @@ test.describe("外围系统", () => {
|
||||
cleaned_value: 0.315, quality: "corrected", reason: "已配置修正的历史快照" }],
|
||||
active_events: [], error: null, created_at: "2026-10-22T00:00:00Z", updated_at: "2026-10-22T01:02:03Z"
|
||||
};
|
||||
await page.route("http://server.test/api/v1/scada-replays**", (route) => {
|
||||
await page.route("http://server.test/api/v1/scada-replays**", async (route) => {
|
||||
const request = route.request();
|
||||
replayRequests.push(request.method());
|
||||
const url = new URL(request.url());
|
||||
if (url.pathname.endsWith("/capabilities")) return route.fulfill({ json: { available: true, can_write: true, reason: null, monitoring_interval_seconds: 900, burst_monitoring_ready: true, burst_monitoring_reason: null, current: replay } });
|
||||
if (url.pathname.endsWith("/capabilities")) return route.fulfill({ json: { available: true, can_write: true, reason: null, monitoring_interval_seconds: 900, burst_monitoring_ready: true, burst_monitoring_reason: null, monitoring_config: monitoringConfig, current: replay } });
|
||||
if (url.pathname.endsWith("/events")) return route.fulfill({ json: { items: [], next_offset: null } });
|
||||
if (url.pathname.endsWith("/observations")) return route.fulfill({ json: { items: replay.latest.filter((row) => Date.parse(row.time) <= Date.parse(url.searchParams.get("through") ?? "")), through: url.searchParams.get("through") } });
|
||||
if (request.method() === "POST" && url.pathname.endsWith("/scada-replays")) {
|
||||
const body = request.postDataJSON() as { start_time: string; end_time: string };
|
||||
replay = { ...replay, replay_id: "60000000-0000-4000-8000-000000000002", status: "ready", revision: 1,
|
||||
start_time: body.start_time, end_time: body.end_time, virtual_now: body.start_time,
|
||||
processed_until: body.start_time, delivered_until: body.start_time, last_monitoring: null, monitoring_count: 0 };
|
||||
return route.fulfill({ status: 201, json: replay });
|
||||
}
|
||||
if (url.pathname.endsWith("/commands")) {
|
||||
const command = request.postDataJSON() as { action: string };
|
||||
const command = request.postDataJSON() as { action: string; at_time?: string };
|
||||
if (command.action === "monitor") {
|
||||
const at = command.at_time!;
|
||||
selectedMonitoringTimes.push(at);
|
||||
if (replay.last_monitoring?.period_end !== at) {
|
||||
replay = { ...replay, revision: replay.revision + 1,
|
||||
status: Date.parse(at) >= Date.parse(replay.end_time) ? "completed" : "paused",
|
||||
virtual_now: at, processed_until: at, delivered_until: at, monitoring_count: replay.monitoring_count + 1,
|
||||
last_monitoring: { monitoring_id: "70000000-0000-4000-8000-000000000002",
|
||||
period_start: replay.virtual_now, period_end: at, knowledge_cutoff: at,
|
||||
batch_sequence: 10, rule_version: "burst-monitor-v2", status: "normal", task_id: null,
|
||||
detail: "选定时刻未发现异常", pressure: {}, outlet_flow: {}, burst_detection: {} }
|
||||
};
|
||||
}
|
||||
return route.fulfill({ json: replay });
|
||||
}
|
||||
if (command.action === "step") {
|
||||
if (replay.monitoring_count === 4) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
return route.fulfill({ status: 503, json: { detail: "监测服务暂不可用,请重试" } });
|
||||
}
|
||||
const start = Date.parse(replay.start_time);
|
||||
const step = replay.monitoring_interval_seconds * 1000;
|
||||
const next = Math.min(Date.parse(replay.end_time), start + (Math.floor((Date.parse(replay.virtual_now) - start) / step) + 1) * step);
|
||||
replay = { ...replay, revision: replay.revision + 1, status: "paused", virtual_now: new Date(next).toISOString(),
|
||||
replay = { ...replay, revision: replay.revision + 1, status: next >= Date.parse(replay.end_time) ? "completed" : "paused", virtual_now: new Date(next).toISOString(),
|
||||
delivered_until: new Date(next).toISOString(), processed_until: new Date(next).toISOString(),
|
||||
monitoring_count: replay.monitoring_count + 1, last_monitoring: {
|
||||
monitoring_id: "70000000-0000-4000-8000-000000000001",
|
||||
period_start: replay.virtual_now, period_end: new Date(next).toISOString(), knowledge_cutoff: new Date(next).toISOString(),
|
||||
batch_sequence: 9, rule_version: "burst-monitor-v1", status: "normal",
|
||||
detail: "本周期爆管相关指标均未命中", task_id: null,
|
||||
pressure: {}, outlet_flow: {}, burst_detection: {}
|
||||
batch_sequence: 9, rule_version: "burst-monitor-v2",
|
||||
status: replay.monitoring_count === 0 ? "normal" : replay.monitoring_count === 1 ? "attention" : replay.monitoring_count === 3 ? "insufficient_data" : "suspected_burst",
|
||||
detail: replay.monitoring_count === 0 ? "本周期爆管相关指标均未命中" : replay.monitoring_count === 1 ? "出厂流量单项异常,进入爆管诊断" : replay.monitoring_count === 3 ? "本周期压力数据不足,继续跟踪原事故" : "爆管相关指标加权达到疑似爆管门槛",
|
||||
task_id: replay.monitoring_count === 0 ? null : "80000000-0000-4000-8000-000000000001",
|
||||
weighted: { score: replay.monitoring_count === 0 || replay.monitoring_count === 3 ? 0 : replay.monitoring_count === 1 ? 0.1 : 0.9,
|
||||
threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
|
||||
incident: replay.monitoring_count === 0 ? null : {
|
||||
incident_id: replay.replay_id, status: "active",
|
||||
action: replay.monitoring_count === 1 ? "raised" : "ongoing", started_at: "2026-10-22T01:30:00Z",
|
||||
last_observed_at: new Date(next).toISOString(), recovery_count: 0
|
||||
},
|
||||
pressure: { status: "ready", hit: replay.monitoring_count === 2 },
|
||||
outlet_flow: { status: "ready", hit: replay.monitoring_count === 1 },
|
||||
burst_detection: { status: "ready", hit: replay.monitoring_count === 2 }
|
||||
} };
|
||||
return route.fulfill({ json: replay });
|
||||
}
|
||||
@@ -276,46 +328,103 @@ test.describe("外围系统", () => {
|
||||
const panel = page.getByRole("complementary", { name: "外围系统" });
|
||||
await panel.getByRole("tab", { name: "SCADA 系统" }).click();
|
||||
|
||||
await expect(panel.getByText("SCADA 监测演示", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("SCADA 数据诊断", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("table", { name: "SCADA 回放数据表" })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "设定读数" })).toHaveCount(0);
|
||||
expect(latestRequests).toEqual(["324ff7ee-c2d4-5184-a84b-48fb14a2933e"]);
|
||||
|
||||
await panel.locator("summary").filter({ hasText: "检测规则" }).click();
|
||||
const monitoringRules = panel.getByLabel("组合监测规则");
|
||||
await expect(monitoringRules).toContainText("权重 50%");
|
||||
await expect(monitoringRules).toContainText("权重 40%");
|
||||
await expect(monitoringRules).toContainText("权重 10%");
|
||||
await expect(monitoringRules).toContainText("综合分 ≥ 0.80");
|
||||
await expect(monitoringRules).toContainText("得分 ≤ -0.0843");
|
||||
await expect(monitoringRules).toContainText("下降阈值 6.50%");
|
||||
await expect(monitoringRules).toContainText("同一异常只报警并建单一次");
|
||||
await expect(panel.getByLabel("压力流量变化阈值")).toHaveCount(0);
|
||||
for (const width of [1280, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await monitoringRules.scrollIntoViewIfNeeded();
|
||||
expect(await monitoringRules.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await testInfo.attach(`scada-rules-${width}`, {
|
||||
body: await monitoringRules.screenshot({ path: testInfo.outputPath(`scada-rules-${width}.png`) }), contentType: "image/png"
|
||||
});
|
||||
}
|
||||
await panel.locator("summary").filter({ hasText: "检测规则" }).click();
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
|
||||
const dial = panel.getByRole("slider", { name: "SCADA 调频时间轴" });
|
||||
await expect(dial).toBeVisible();
|
||||
await expect(dial).toHaveAttribute("data-step-ms", "300000");
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-10-22T01:00:00Z")));
|
||||
await expect(panel.getByLabel("SCADA 演示状态")).toContainText("15 分钟");
|
||||
await expect(panel.getByLabel("SCADA 演示状态")).toContainText("180×");
|
||||
await expect(panel.getByLabel("SCADA 演示状态")).toContainText("5 秒");
|
||||
await expect(dial).toHaveCSS("user-select", "none");
|
||||
expect(await dial.evaluate((element) => getComputedStyle(element, "::after").backgroundImage)).toContain("linear-gradient");
|
||||
expect(await dial.locator(".scada-fm-scale").evaluate((element) => getComputedStyle(element).maskImage)).toContain("linear-gradient");
|
||||
await expect(panel.locator(".scada-fm-needle")).toHaveCSS("background-color", "rgb(207, 69, 63)");
|
||||
const needle = panel.locator(".scada-fm-needle");
|
||||
await expect(needle).toHaveCSS("background-color", "rgb(207, 69, 63)");
|
||||
expect(await needle.evaluate((element) => getComputedStyle(element).clipPath)).toContain("polygon");
|
||||
const needleBox = await needle.boundingBox();
|
||||
expect(needleBox!.width).toBeCloseTo(10, 5);
|
||||
expect(needleBox!.height).toBeCloseTo(64, 5);
|
||||
expect(needleBox!.height - 12).toBeCloseTo(52, 5);
|
||||
await expect.poll(() => dial.getByText("09:00", { exact: true }).evaluate((element) =>
|
||||
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
|
||||
)).toBeGreaterThan(1.4);
|
||||
expect(await dial.getByText("08:50", { exact: true }).evaluate((element) =>
|
||||
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
|
||||
)).toBeCloseTo(1, 2);
|
||||
const restingShadow = await dial.evaluate((element) => getComputedStyle(element).boxShadow);
|
||||
await dial.focus();
|
||||
await expect(dial).toHaveCSS("outline-style", "none");
|
||||
expect(await dial.evaluate((element) => getComputedStyle(element).boxShadow)).toBe(restingShadow);
|
||||
await dial.press("Home");
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
await dial.press("ArrowRight");
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time) + 300000));
|
||||
await expect(dial).toHaveAttribute("data-snapping", "true");
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
await dial.press("ArrowLeft");
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
for (let step = 0; step < 12; step += 1) await dial.press("ArrowRight");
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
await expect.poll(() => dial.getByText("09:00", { exact: true }).evaluate((element) =>
|
||||
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
|
||||
)).toBeCloseTo(1.55, 2);
|
||||
const leftStepFrame = await dial.evaluate(async (element) => {
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const needle = element.querySelector(".scada-fm-needle")!.getBoundingClientRect();
|
||||
const rightLabel = [...element.querySelectorAll("span")]
|
||||
.find((candidate) => candidate.textContent === "09:15")?.getBoundingClientRect();
|
||||
return {
|
||||
snapping: element.getAttribute("data-snapping"),
|
||||
rightLabelRetained: Boolean(rightLabel
|
||||
&& rightLabel.x + rightLabel.width / 2 > needle.x + needle.width / 2)
|
||||
};
|
||||
});
|
||||
expect(leftStepFrame).toEqual({ snapping: "true", rightLabelRetained: true });
|
||||
await testInfo.attach("scada-tuning-keyboard-motion", {
|
||||
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-keyboard-motion.png") }), contentType: "image/png"
|
||||
});
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
await dial.press("ArrowRight");
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
const centeredLabel = await dial.getByText("09:00", { exact: true }).boundingBox();
|
||||
const centeredNeedle = await panel.locator(".scada-fm-needle").boundingBox();
|
||||
expect(centeredNeedle!.y + centeredNeedle!.height).toBeLessThan(centeredLabel!.y);
|
||||
expect(centeredNeedle!.y + centeredNeedle!.height).toBeLessThan(centeredLabel!.y + 3);
|
||||
await testInfo.attach("scada-tuning-center", {
|
||||
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-center.png") }), contentType: "image/png"
|
||||
});
|
||||
await dial.press("End");
|
||||
await expect(panel.getByText("该时间尚未完成监测", { exact: true })).toBeVisible();
|
||||
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
|
||||
await panel.getByRole("button", { name: "回到监测时间", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "回到开始时间", exact: true }).click();
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
|
||||
const dragBox = await dial.boundingBox();
|
||||
expect(dragBox).not.toBeNull();
|
||||
await page.mouse.move(dragBox!.x + dragBox!.width / 2, dragBox!.y + 45);
|
||||
@@ -323,23 +432,38 @@ test.describe("外围系统", () => {
|
||||
await expect(dial).toHaveAttribute("data-dragging", "true");
|
||||
await expect(dial).toHaveCSS("cursor", "grabbing");
|
||||
await page.mouse.move(dragBox!.x + dragBox!.width / 2 - 120, dragBox!.y + 45, { steps: 8 });
|
||||
await expect(panel.getByText("该时间尚未完成监测", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("已定位到历史监测时间", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("时间定位", { exact: true })).toBeVisible();
|
||||
await testInfo.attach("scada-tuning-dragging", {
|
||||
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-dragging.png") }), contentType: "image/png"
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(dial).toHaveAttribute("data-dragging", "false");
|
||||
expect(Number(await dial.getAttribute("aria-valuenow")) % 300000).toBe(0);
|
||||
const snappedTime = Number(await dial.getAttribute("aria-valuenow"));
|
||||
expect(snappedTime % 300000).toBe(0);
|
||||
const snappedLabel = new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit", minute: "2-digit", hour12: false
|
||||
}).format(snappedTime);
|
||||
await testInfo.attach("scada-tuning-snapping", {
|
||||
body: await dial.screenshot({ path: testInfo.outputPath("scada-tuning-snapping.png") }), contentType: "image/png"
|
||||
});
|
||||
await expect(dial).toHaveAttribute("data-snapping", "false");
|
||||
await expect.poll(async () => {
|
||||
const label = await dial.getByText(snappedLabel, { exact: true }).boundingBox();
|
||||
const needle = await panel.locator(".scada-fm-needle").boundingBox();
|
||||
return label && needle
|
||||
? Math.abs(needle.x + needle.width / 2 - label.x - label.width / 2)
|
||||
: Infinity;
|
||||
}).toBeLessThan(2);
|
||||
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
|
||||
await panel.getByRole("button", { name: "回到监测时间", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "回到开始时间", exact: true }).click();
|
||||
expect(replayRequests.filter((method) => method === "POST")).toHaveLength(0);
|
||||
for (const dialWidth of [1280, 375, 320]) {
|
||||
await page.setViewportSize({ width: dialWidth, height: 1000 });
|
||||
await dial.scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(dialWidth);
|
||||
await expect.poll(async () => {
|
||||
const tick = await dial.getByText("09:00", { exact: true }).boundingBox();
|
||||
const tick = await dial.getByText("08:00", { exact: true }).boundingBox();
|
||||
const needle = await panel.locator(".scada-fm-needle").boundingBox();
|
||||
return tick && needle ? Math.abs(needle.x + needle.width / 2 - tick.x - tick.width / 2) : Infinity;
|
||||
}).toBeLessThan(2);
|
||||
@@ -354,11 +478,186 @@ test.describe("外围系统", () => {
|
||||
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-10-22T01:15:00Z")));
|
||||
await expect(panel.getByText("本周期监测正常", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("监测正常", { exact: true })).toBeVisible();
|
||||
await testInfo.attach("scada-monitoring-result", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("scada-monitoring-result.png") }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(panel.getByText("发现指标异常", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("已启动调查", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByLabel("综合评分")).toHaveCount(0);
|
||||
const anomalyNotice = panel.getByRole("status", { name: "最新监测结果" });
|
||||
await expect(anomalyNotice).toHaveAttribute("data-animation-state", "running");
|
||||
const pulse = anomalyNotice.locator("[data-slot=scada-alert-pulse]");
|
||||
const pulseStart = await pulse.evaluate((element) => getComputedStyle(element).transform);
|
||||
const emphasis = anomalyNotice.locator("[data-slot=scada-alert-emphasis]");
|
||||
const emphasisStart = await emphasis.evaluate((element) => getComputedStyle(element).opacity);
|
||||
await page.waitForTimeout(180);
|
||||
expect(await pulse.evaluate((element) => getComputedStyle(element).transform)).not.toBe(pulseStart);
|
||||
expect(await emphasis.evaluate((element) => getComputedStyle(element).opacity)).not.toBe(emphasisStart);
|
||||
await testInfo.attach("scada-first-accident", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("scada-first-accident.png") }), contentType: "image/png"
|
||||
});
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(panel.getByText("异常持续跟踪", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("沿用原调查")).toBeVisible();
|
||||
await expect.poll(async () => {
|
||||
const [label, needle, window] = await Promise.all([
|
||||
dial.getByText("09:50", { exact: true }).boundingBox(),
|
||||
panel.locator(".scada-fm-needle").boundingBox(), dial.boundingBox()
|
||||
]);
|
||||
return Boolean(label && needle && window && label.x > needle.x && label.x + label.width < window.x + window.width);
|
||||
}).toBe(true);
|
||||
await expect(panel.getByText("判定依据", { exact: true })).toHaveCount(0);
|
||||
await expect(anomalyNotice).toHaveAttribute("data-animation-state", "idle");
|
||||
for (const width of [1280, 375, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
const result = panel.getByRole("status", { name: "最新监测结果" });
|
||||
await result.scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
expect(await result.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
const metrics = panel.getByLabel("SCADA 演示状态").locator("dd");
|
||||
const metricTops = await metrics.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().top));
|
||||
expect(Math.max(...metricTops) - Math.min(...metricTops)).toBeLessThanOrEqual(1);
|
||||
const controls = panel.getByRole("group", { name: "SCADA 播放控制" });
|
||||
const buttons = await controls.getByRole("button").evaluateAll((elements) => elements.map((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const content = range.getBoundingClientRect();
|
||||
return { y: bounds.y, height: bounds.height, fits: content.left >= bounds.left && content.right <= bounds.right };
|
||||
}));
|
||||
expect(buttons).toHaveLength(3);
|
||||
expect(buttons.map((button) => button.height)).toEqual([40, 40, 40]);
|
||||
expect(Math.max(...buttons.map((button) => button.y)) - Math.min(...buttons.map((button) => button.y))).toBeLessThanOrEqual(0.5);
|
||||
expect(buttons.map((button) => button.fits)).toEqual([true, true, true]);
|
||||
await testInfo.attach(`scada-playback-controls-${width}`, {
|
||||
body: await controls.screenshot({ path: testInfo.outputPath(`scada-playback-controls-${width}.png`) }), contentType: "image/png"
|
||||
});
|
||||
const playbackSurface = panel.getByRole("region", { name: "SCADA 回放控制", exact: true });
|
||||
expect(await playbackSurface.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await expect(playbackSurface.getByRole("button", { name: "监测选定时刻" })).toBeEnabled();
|
||||
await testInfo.attach(`scada-playback-layout-${width}`, {
|
||||
body: await playbackSurface.screenshot({ path: testInfo.outputPath(`scada-playback-layout-${width}.png`) }), contentType: "image/png"
|
||||
});
|
||||
expect((await result.getByText("异常持续跟踪", { exact: true }).boundingBox())!.height).toBeLessThanOrEqual(24);
|
||||
const box = await result.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(width);
|
||||
await testInfo.attach(`scada-ongoing-${width}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath(`scada-ongoing-${width}.png`) }), contentType: "image/png"
|
||||
});
|
||||
}
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(panel.getByText("本周期压力数据不足,继续跟踪原事故")).toHaveCount(0);
|
||||
await expect(panel.getByText("异常持续跟踪", { exact: true })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(panel.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
|
||||
await expect(panel.getByRole("status", { name: "最新监测结果" })).toHaveAttribute("aria-busy", "true");
|
||||
await expect(panel.getByRole("alert")).toContainText("监测服务暂不可用,请重试");
|
||||
await testInfo.attach("scada-monitoring-error", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("scada-monitoring-error.png") }), contentType: "image/png"
|
||||
});
|
||||
await panel.getByRole("button", { name: "切换到 Case3", exact: true }).click();
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse("2026-07-16T07:30:00+08:00")));
|
||||
await expect(dial).toHaveAttribute("aria-valuemin", String(Date.parse("2026-07-16T07:30:00+08:00")));
|
||||
await expect(dial).toHaveAttribute("aria-valuemax", String(Date.parse("2026-07-16T08:30:00+08:00")));
|
||||
await expect(panel.getByText("2026-07-16 07:30", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("2026-07-16 08:30", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("采样范围与基础规则", { exact: true })).toHaveCount(0);
|
||||
await expect(panel.getByRole("status", { name: "最新监测结果" })).toContainText("监测待机");
|
||||
await page.setViewportSize({ width: 1280, height: 1000 });
|
||||
await testInfo.attach("scada-case3-hour", {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath("scada-case3-hour.png") }), contentType: "image/png"
|
||||
});
|
||||
const play = panel.getByRole("button", { name: "播放", exact: true });
|
||||
const pause = panel.getByRole("button", { name: "暂停", exact: true });
|
||||
await play.click();
|
||||
await expect(play).toBeDisabled();
|
||||
await expect(pause).toBeEnabled();
|
||||
await expect.poll(() => replay.monitoring_count, { timeout: 20000 }).toBe(2);
|
||||
await expect(anomalyNotice).toContainText("发现指标异常");
|
||||
await expect(anomalyNotice).toHaveAttribute("data-animation-state", "running");
|
||||
await testInfo.attach("scada-autoplay-anomaly", {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath("scada-autoplay-anomaly.png") }), contentType: "image/png"
|
||||
});
|
||||
await pause.click();
|
||||
await expect(play).toBeEnabled();
|
||||
await expect(pause).toBeDisabled();
|
||||
const pausedCount = replay.monitoring_count;
|
||||
await page.waitForTimeout(800);
|
||||
expect(replay.monitoring_count).toBe(pausedCount);
|
||||
await play.click();
|
||||
await expect.poll(() => replay.status, { timeout: 20000 }).toBe("completed");
|
||||
await expect(play).toBeDisabled();
|
||||
await expect(pause).toBeDisabled();
|
||||
await expect(panel.getByRole("button", { name: "重新开始", exact: true })).toBeEnabled();
|
||||
expect(replay.monitoring_count).toBe(4);
|
||||
await expect(anomalyNotice).toHaveAttribute("data-animation-state", "idle");
|
||||
await page.waitForTimeout(800);
|
||||
expect(replay.monitoring_count).toBe(4);
|
||||
|
||||
await panel.getByRole("button", { name: "重新开始", exact: true }).click();
|
||||
await expect(play).toBeEnabled();
|
||||
await expect(pause).toBeDisabled();
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
|
||||
await expect(anomalyNotice).toContainText("监测待机");
|
||||
expect(replay.monitoring_count).toBe(0);
|
||||
await page.waitForTimeout(800);
|
||||
expect(replay.monitoring_count).toBe(0);
|
||||
await play.click();
|
||||
await expect(pause).toBeEnabled();
|
||||
await pause.click();
|
||||
await expect(play).toBeEnabled();
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
replay = { ...replay, replay_id: "60000000-0000-4000-8000-000000000003", status: "ready",
|
||||
virtual_now: replay.start_time, processed_until: replay.start_time, delivered_until: replay.start_time,
|
||||
monitoring_count: 0, last_monitoring: null };
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await panel.getByRole("tab", { name: "SCADA 系统" }).click();
|
||||
await expect(anomalyNotice).toContainText("监测待机", { timeout: 8000 });
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(panel.getByText("监测正常", { exact: true })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "下一步并监测", exact: true }).click();
|
||||
await expect(anomalyNotice).toContainText("发现指标异常");
|
||||
await expect(anomalyNotice).toHaveAttribute("data-animation-state", "idle");
|
||||
await expect(anomalyNotice.locator("[data-slot=scada-alert-pulse]")).toHaveCount(0);
|
||||
await testInfo.attach("scada-reduced-motion", {
|
||||
body: await panel.screenshot({ path: testInfo.outputPath("scada-reduced-motion.png") }), contentType: "image/png"
|
||||
});
|
||||
|
||||
const progressBeforeBrowsing = replay.virtual_now;
|
||||
await dial.press("End");
|
||||
await panel.getByRole("button", { name: "回到开始时间", exact: true }).click();
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
|
||||
expect(replay.virtual_now).toBe(progressBeforeBrowsing);
|
||||
const monitorSelection = panel.getByRole("button", { name: "监测选定时刻" });
|
||||
const replayBeforeHistory = replay.replay_id;
|
||||
await expect(panel.getByText("监测此时刻将建立新一轮回放,原有调查记录保留。")).toBeVisible();
|
||||
await monitorSelection.click();
|
||||
await expect(monitorSelection).toBeEnabled();
|
||||
await expect.poll(() => selectedMonitoringTimes.at(-1)).toBe(new Date(replay.start_time).toISOString());
|
||||
expect(replay.replay_id).not.toBe(replayBeforeHistory);
|
||||
expect(replay.virtual_now).toBe(new Date(replay.start_time).toISOString());
|
||||
await dial.press("ArrowRight");
|
||||
const selectedTime = Date.parse(replay.start_time) + 300000;
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(selectedTime));
|
||||
await monitorSelection.click();
|
||||
await expect.poll(() => selectedMonitoringTimes.at(-1)).toBe(new Date(selectedTime).toISOString());
|
||||
expect(replay.virtual_now).toBe(new Date(selectedTime).toISOString());
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", String(selectedTime));
|
||||
const selectedCount = replay.monitoring_count;
|
||||
await monitorSelection.click();
|
||||
await expect(monitorSelection).toBeEnabled();
|
||||
expect(replay.monitoring_count).toBe(selectedCount);
|
||||
await testInfo.attach("scada-selected-monitoring", {
|
||||
body: await panel.getByRole("region", { name: "SCADA 回放控制", exact: true }).screenshot({ path: testInfo.outputPath("selected-monitoring.png") }), contentType: "image/png"
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
test("项目分析成果打开时保留地图遮挡", async ({ page }) => {
|
||||
@@ -462,6 +761,21 @@ test.describe("外围系统", () => {
|
||||
let workOrders: Record<string, unknown>[] = [];
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
let deleteRequests = 0;
|
||||
const resolvedAddress = "上海市奉贤区演示路与样板路交叉口";
|
||||
let reverseUnavailable = true;
|
||||
const reverseRequests: { longitude: number; latitude: number; projectId: string | undefined }[] = [];
|
||||
await page.route("http://server.test/api/v1/reverse-geocoding-requests", (route) => {
|
||||
const coordinates = route.request().postDataJSON() as { longitude: number; latitude: number };
|
||||
reverseRequests.push({ ...coordinates, projectId: route.request().headers()["x-project-id"] });
|
||||
if (reverseUnavailable) {
|
||||
reverseUnavailable = false;
|
||||
return route.fulfill({ status: 503, json: { detail: "当前项目暂未配置地名搜索" } });
|
||||
}
|
||||
return route.fulfill({ json: {
|
||||
id: "event-location-test", name: "演示路与样板路交叉口", address: resolvedAddress,
|
||||
...coordinates, category: "地址", district: "奉贤区"
|
||||
} });
|
||||
});
|
||||
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://peripheral.test" })}`
|
||||
@@ -487,12 +801,12 @@ test.describe("外围系统", () => {
|
||||
if (path.includes("/work-orders/") && request.method() === "PATCH") {
|
||||
const body = request.postDataJSON();
|
||||
writes.push(body);
|
||||
const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z", activity: [...workOrders[0].activity, { sequence: 2, occurred_at: "2026-09-18T01:00:00Z", actor: "tester", kind: "feedback", title: "现场反馈", detail: body.progress_note }] };
|
||||
const next = { ...workOrders[0], ...body, revision: 2, updated_at: "2026-09-18T01:00:00Z", activity: [...workOrders[0].activity, { sequence: 2, occurred_at: body.occurred_at || "2026-09-18T01:00:00Z", actor: "tester", kind: "feedback", title: "现场反馈", detail: body.progress_note }] };
|
||||
if (body.clear_assignee) next.assignee = null;
|
||||
if (body.clear_location) next.location = null;
|
||||
if (body.clear_scenario_code) next.scenario_code = null;
|
||||
if (body.clear_scheduled_at) next.scheduled_at = null;
|
||||
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "progress_note"]) delete next[key];
|
||||
for (const key of ["request_id", "expected_revision", "clear_assignee", "clear_location", "clear_scenario_code", "clear_scheduled_at", "progress_note", "occurred_at"]) delete next[key];
|
||||
workOrders = [next];
|
||||
return route.fulfill({ json: workOrders[0] });
|
||||
}
|
||||
@@ -511,7 +825,7 @@ test.describe("外围系统", () => {
|
||||
await expect(panel.getByRole("textbox", { name: "标题" })).toBeHidden();
|
||||
await panel.getByRole("button", { name: "新增预约工单" }).click();
|
||||
let editor = panel.getByRole("region", { name: "新增预约工单" });
|
||||
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修");
|
||||
await editor.getByRole("textbox", { name: "标题" }).fill("预约维修");
|
||||
await editor.getByRole("textbox", { name: "说明", exact: true }).fill("验证外围过程结构同步");
|
||||
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
|
||||
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
|
||||
@@ -534,7 +848,7 @@ test.describe("外围系统", () => {
|
||||
await expect(editor.getByRole("button", { name: "预约时间", exact: true })).toBeFocused();
|
||||
await editor.getByRole("textbox", { name: "回执要求(每行一项)" }).fill("回传现场处理结果\n上传照片");
|
||||
await editor.getByRole("button", { name: "创建预约工单" }).click();
|
||||
await expect(panel.getByText("合成预约维修", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("预约维修", { exact: true })).toBeVisible();
|
||||
expect(writes[0]).toMatchObject({
|
||||
work_order_type: "general",
|
||||
assignee: "测试班组甲",
|
||||
@@ -543,9 +857,9 @@ test.describe("外围系统", () => {
|
||||
feedback_requirements: ["回传现场处理结果", "上传照片"]
|
||||
});
|
||||
|
||||
await panel.getByRole("button", { name: "编辑工单 合成预约维修" }).click();
|
||||
editor = panel.getByRole("region", { name: "编辑工单 合成预约维修" });
|
||||
await expect(panel.getByLabel("预约工单列表").getByRole("region", { name: "编辑工单 合成预约维修" })).toBeVisible();
|
||||
await panel.getByRole("button", { name: "编辑工单 预约维修" }).click();
|
||||
editor = panel.getByRole("region", { name: "编辑工单 预约维修" });
|
||||
await expect(panel.getByLabel("预约工单列表").getByRole("region", { name: "编辑工单 预约维修" })).toBeVisible();
|
||||
await testInfo.attach("work-order-editor-text", {
|
||||
body: await editor.screenshot({ path: testInfo.outputPath("work-order-editor.png") }), contentType: "image/png"
|
||||
});
|
||||
@@ -560,19 +874,36 @@ test.describe("外围系统", () => {
|
||||
});
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await expect(editor.locator("xpath=ancestor::article")).toContainText("合成预约维修");
|
||||
await expect(editor.locator("xpath=ancestor::article")).toContainText("预约维修");
|
||||
await expect(editor.getByRole("textbox", { name: "标题" })).toBeFocused();
|
||||
await expect(panel.getByRole("button", { name: "编辑工单 合成预约维修" })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "删除工单 合成预约维修" })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "编辑工单 预约维修" })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "删除工单 预约维修" })).toHaveCount(0);
|
||||
expect(await editor.getByRole("textbox", { name: "标题" }).evaluate((input) => getComputedStyle(input).outlineWidth)).toBe("0px");
|
||||
await editor.getByRole("textbox", { name: "标题" }).fill("合成预约维修(已对齐)");
|
||||
await editor.getByRole("textbox", { name: "标题" }).fill("预约维修(已对齐)");
|
||||
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组乙");
|
||||
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点东侧");
|
||||
await editor.getByRole("textbox", { name: "现场反馈(可选)" }).fill("班组乙已确认东侧作业窗口,等待复核。");
|
||||
await editor.getByRole("button", { name: "现场反馈时间(可选)", exact: true }).click();
|
||||
const feedbackCalendar = page.getByRole("dialog", { name: "选择现场反馈时间(可选)", exact: true });
|
||||
await feedbackCalendar.getByRole("textbox", { name: "年份", exact: true }).fill("2026");
|
||||
await feedbackCalendar.getByRole("combobox", { name: "月份", exact: true }).click();
|
||||
await page.getByRole("option", { name: "7月", exact: true }).click();
|
||||
await feedbackCalendar.getByRole("gridcell", { name: "2026年7月16日", exact: true }).click();
|
||||
await feedbackCalendar.getByRole("combobox", { name: "小时", exact: true }).click();
|
||||
await page.getByRole("option", { name: "09", exact: true }).click();
|
||||
await feedbackCalendar.getByRole("combobox", { name: "分钟", exact: true }).click();
|
||||
await page.getByRole("option", { name: "00", exact: true }).click();
|
||||
await feedbackCalendar.getByRole("button", { name: "确认", exact: true }).click();
|
||||
await expect(editor.getByRole("button", { name: "现场反馈时间(可选)", exact: true })).toContainText("2026-07-16 09:00");
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||||
await editor.getByRole("button", { name: "现场反馈时间(可选)", exact: true }).scrollIntoViewIfNeeded();
|
||||
await testInfo.attach("completion-business-time", { body: await page.screenshot({ path: testInfo.outputPath("completion-business-time.png") }), contentType: "image/png" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await editor.getByRole("combobox", { name: "工单状态" }).click();
|
||||
await page.getByRole("option", { name: "待复令", exact: true }).click();
|
||||
await editor.getByRole("button", { name: "保存工单" }).click();
|
||||
await expect(panel.getByText("合成预约维修(已对齐)", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("预约维修(已对齐)", { exact: true })).toBeVisible();
|
||||
expect(writes[1]).toMatchObject({
|
||||
status: "awaiting_reply",
|
||||
assignee: "测试班组乙",
|
||||
@@ -580,6 +911,7 @@ test.describe("外围系统", () => {
|
||||
clear_assignee: false,
|
||||
clear_location: false,
|
||||
progress_note: "班组乙已确认东侧作业窗口,等待复核。",
|
||||
occurred_at: "2026-07-16T01:00:00.000Z",
|
||||
feedback_requirements: ["回传现场处理结果", "上传照片"]
|
||||
});
|
||||
expect(workOrders[0]).toMatchObject({ work_order_type: "general", scenario_code: "APPOINTMENT-TEST-001" });
|
||||
@@ -638,22 +970,122 @@ test.describe("外围系统", () => {
|
||||
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
|
||||
route.fulfill({ status: 503, json: { detail: "operational task test stub" } })
|
||||
);
|
||||
await page.setViewportSize({ width: 1280, height: 780 });
|
||||
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
|
||||
await panel.getByRole("combobox", { name: "事件类型" }).click();
|
||||
await page.getByRole("option", { name: "爆管上报", exact: true }).click();
|
||||
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示路与样板路交叉口");
|
||||
const locationPicker = panel.getByRole("region", { name: "事件主地图选点" });
|
||||
await expect(locationPicker).toContainText("等待拖到主地图");
|
||||
const pickerIcon = locationPicker.getByRole("button", { name: "拖拽定位图标到主地图" });
|
||||
await expect(pickerIcon.locator("[data-event-location-pin]")).toBeVisible();
|
||||
await expect(pickerIcon).toHaveCSS("border-width", "0px");
|
||||
await expect(pickerIcon).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
const pickerIconBox = await pickerIcon.boundingBox();
|
||||
const addressBox = await locationPicker.getByRole("textbox", { name: "事件位置" }).boundingBox();
|
||||
const mainMapBox = await page.getByLabel("供水管网地图", { exact: true }).boundingBox();
|
||||
if (!pickerIconBox || !addressBox || !mainMapBox) throw new Error("地址输入、选点图钉或主地图不可见");
|
||||
const eventTypeBox = await panel.getByRole("combobox", { name: "事件类型" }).boundingBox();
|
||||
if (!eventTypeBox) throw new Error("事件类型不可见");
|
||||
const occurredAtBox = await panel.getByRole("button", { name: "发生时间", exact: true }).boundingBox();
|
||||
if (!occurredAtBox) throw new Error("发生时间不可见");
|
||||
expect(Math.abs(eventTypeBox.y - occurredAtBox.y)).toBeLessThan(0.5);
|
||||
expect(Math.abs(eventTypeBox.height - occurredAtBox.height)).toBeLessThan(0.5);
|
||||
expect(Math.abs(addressBox.x - eventTypeBox.x)).toBeLessThan(0.5);
|
||||
expect(Math.abs(addressBox.width - eventTypeBox.width)).toBeLessThan(0.5);
|
||||
expect(Math.abs(pickerIconBox.y + pickerIconBox.height / 2 - addressBox.y - addressBox.height / 2)).toBeLessThan(0.5);
|
||||
expect(pickerIconBox.x - addressBox.x - addressBox.width).toBeGreaterThanOrEqual(8);
|
||||
await page.mouse.move(pickerIconBox.x + pickerIconBox.width / 2, pickerIconBox.y + pickerIconBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(mainMapBox.x + mainMapBox.width * 0.22, mainMapBox.y + mainMapBox.height * 0.42, { steps: 10 });
|
||||
await expect(page.locator("[data-event-location-drag-preview]")).toBeVisible();
|
||||
await expect(page.locator("[data-event-location-drag-preview]")).toHaveCSS("box-shadow", "none");
|
||||
await expect(pickerIcon).toHaveCSS("outline-width", "0px");
|
||||
await expect(locationPicker.getByRole("textbox", { name: "事件位置" })).not.toBeFocused();
|
||||
await expect(page.locator("[data-event-location-drag-pin] [data-event-location-pin]")).toBeVisible();
|
||||
await testInfo.attach("event-location-picker-dragging", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("event-location-picker-dragging.png") }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect(locationPicker.getByLabel("GPS 坐标")).not.toHaveText("等待拖到主地图");
|
||||
await expect(locationPicker.getByRole("status", { name: "地址查询状态" })).toContainText("当前项目暂未配置地名搜索");
|
||||
await expect(locationPicker.getByRole("textbox", { name: "事件位置" })).toHaveValue("");
|
||||
await testInfo.attach("event-location-address-unconfigured", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("event-location-address-unconfigured.png") }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
await locationPicker.getByRole("button", { name: "重试地址查询" }).click();
|
||||
await expect(locationPicker.getByRole("textbox", { name: "事件位置" })).toHaveValue(resolvedAddress);
|
||||
const gpsAfterMapDrop = (await locationPicker.getByLabel("GPS 坐标").textContent())?.trim();
|
||||
await testInfo.attach("event-location-picker", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("event-location-picker.png") }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
const marker = page.getByLabel("事件位置标记,可拖拽调整");
|
||||
await expect(marker.locator("[data-event-location-pin]")).toBeVisible();
|
||||
const pinAnchorError = () => marker.evaluate((element) => {
|
||||
const svg = element.querySelector("svg");
|
||||
const matrix = svg?.getScreenCTM();
|
||||
if (!matrix) throw new Error("图钉尚未渲染");
|
||||
const tip = new DOMPoint(40, 80).matrixTransform(matrix);
|
||||
const box = element.getBoundingClientRect();
|
||||
return Math.hypot(tip.x - box.x - box.width / 2, tip.y - box.bottom);
|
||||
});
|
||||
await expect.poll(pinAnchorError).toBeLessThan(0.5);
|
||||
const markerBox = await marker.boundingBox();
|
||||
if (!markerBox) throw new Error("地图图钉不可见");
|
||||
await page.mouse.move(markerBox.x + markerBox.width * 0.65, markerBox.y + markerBox.height * 0.3);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(markerBox.x + markerBox.width * 0.65 + 45, markerBox.y + markerBox.height * 0.3 + 20, { steps: 8 });
|
||||
await expect(marker).toHaveAttribute("data-dragging", "true");
|
||||
await expect(marker).toHaveCSS("outline-width", "0px");
|
||||
await expect.poll(pinAnchorError).toBeLessThan(0.5);
|
||||
await testInfo.attach("event-location-pin-adjusting", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("event-location-pin-adjusting.png") }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
await page.mouse.up();
|
||||
await expect.poll(pinAnchorError).toBeLessThan(0.5);
|
||||
await expect(locationPicker.getByLabel("GPS 坐标")).not.toHaveText(gpsAfterMapDrop ?? "");
|
||||
const gpsAfterMarkerDrag = (await locationPicker.getByLabel("GPS 坐标").textContent())?.trim();
|
||||
await marker.focus();
|
||||
await marker.press("ArrowRight");
|
||||
await expect(locationPicker.getByLabel("GPS 坐标")).not.toHaveText(gpsAfterMarkerDrag ?? "");
|
||||
const selectedGps = (await locationPicker.getByLabel("GPS 坐标").textContent())?.trim();
|
||||
expect(selectedGps).toMatch(/^121\.\d{6}, 30\.\d{6}$/);
|
||||
await expect(locationPicker.getByRole("textbox", { name: "事件位置" })).toHaveValue(resolvedAddress);
|
||||
expect(reverseRequests.at(-1)?.projectId).toBe(projectId);
|
||||
const [selectedLongitude, selectedLatitude] = selectedGps!.split(", ").map(Number);
|
||||
expect(reverseRequests.at(-1)?.longitude).toBeCloseTo(selectedLongitude, 6);
|
||||
expect(reverseRequests.at(-1)?.latitude).toBeCloseTo(selectedLatitude, 6);
|
||||
for (const width of [375, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await locationPicker.scrollIntoViewIfNeeded();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
|
||||
const compactAddress = await locationPicker.getByRole("textbox", { name: "事件位置" }).boundingBox();
|
||||
const compactPin = await pickerIcon.boundingBox();
|
||||
if (!compactAddress || !compactPin) throw new Error("窄屏位置字段不可见");
|
||||
expect(Math.abs(compactPin.y + compactPin.height / 2 - compactAddress.y - compactAddress.height / 2)).toBeLessThan(0.5);
|
||||
await testInfo.attach(`event-location-picker-${width}`, {
|
||||
body: await locationPicker.screenshot({ path: testInfo.outputPath(`event-location-picker-${width}.png`) }),
|
||||
contentType: "image/png"
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 780 });
|
||||
await panel.getByRole("textbox", { name: "现场描述" }).fill("路面积水并伴随压力突降");
|
||||
await expectThemeColor(panel.getByRole("textbox", { name: "事件位置" }), "color", "--ink");
|
||||
await expectThemeColor(panel.getByRole("textbox", { name: "现场描述" }), "color", "--ink");
|
||||
await panel.getByRole("button", { name: "创建事件工单" }).click();
|
||||
await expect(panel.getByText("爆管上报工单", { exact: true }).last()).toBeVisible();
|
||||
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报:演示路与样板路交叉口");
|
||||
await expect(panel.getByLabel("爆管上报工单列表")).toContainText(`爆管上报:${resolvedAddress}`);
|
||||
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报工单已创建。");
|
||||
expect(workOrders[0]).toMatchObject({
|
||||
work_order_type: "burst_report",
|
||||
scenario_code: null,
|
||||
origin: "system_data",
|
||||
title: "爆管上报:演示路与样板路交叉口",
|
||||
title: `爆管上报:${resolvedAddress}(GPS:${selectedGps})`,
|
||||
location: `${resolvedAddress}(GPS:${selectedGps})`,
|
||||
status: "open",
|
||||
priority: "urgent"
|
||||
});
|
||||
@@ -722,7 +1154,7 @@ test.describe("外围系统", () => {
|
||||
const plan = {
|
||||
schema: "execution-plan@1", title: "水质投诉调查计划", reason: "先完成四路并行核查,汇合后再分两路核实",
|
||||
nodes: [
|
||||
{ id: "accept", title: "接收投诉工单", status: "completed", owner: "agent", detail: "已关联合成工单", evidence_refs: ["e-1"], action_refs: [] },
|
||||
{ id: "accept", title: "接收投诉工单", status: "completed", owner: "agent", detail: "已关联工单", evidence_refs: ["e-1"], action_refs: [] },
|
||||
{ id: "upstream", title: "校验上游水质监测", status: "completed", owner: "agent", detail: "上游三个点位指标正常", evidence_refs: ["e-1"], action_refs: [] },
|
||||
{ id: "upstream-review", title: "复核上游趋势", status: "completed", owner: "agent", detail: "过去两小时趋势稳定", evidence_refs: ["e-1"], action_refs: [] },
|
||||
{ id: "nearby", title: "核对邻近投诉", status: "running", owner: "agent", detail: "检查投诉空间分布", evidence_refs: [], action_refs: [] },
|
||||
@@ -1052,9 +1484,9 @@ function createDemoDataset(projectId: string) {
|
||||
|
||||
function createSourceWorkOrders(projectId: string) {
|
||||
const appointments = [
|
||||
["合成通信核查", "核查监测点通信和数据上送状态", "open", "high"],
|
||||
["合成压力偏差复核", "复核压力偏差与历史回放证据", "in_progress", "urgent"],
|
||||
["合成零流量样本确认", "确认零流量样本及质量标记", "completed", "normal"]
|
||||
["通信核查", "核查监测点通信和数据上送状态", "open", "high"],
|
||||
["压力偏差复核", "复核压力偏差与历史回放证据", "in_progress", "urgent"],
|
||||
["零流量样本确认", "确认零流量样本及质量标记", "completed", "normal"]
|
||||
].map(([title, summary, status, priority], index) => ({
|
||||
work_order_id: `50000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
|
||||
project_id: projectId,
|
||||
@@ -1070,11 +1502,11 @@ function createSourceWorkOrders(projectId: string) {
|
||||
scenario_code: null,
|
||||
feedback_requirements: [],
|
||||
activity: [],
|
||||
origin: "synthetic",
|
||||
origin: "system_data",
|
||||
created_at: "2026-09-17T00:00:00Z",
|
||||
updated_at: "2026-09-17T00:00:00Z",
|
||||
created_by: "synthetic-generator",
|
||||
updated_by: "synthetic-generator"
|
||||
created_by: "work-order-service",
|
||||
updated_by: "work-order-service"
|
||||
}));
|
||||
const events = [
|
||||
{
|
||||
@@ -1084,7 +1516,7 @@ function createSourceWorkOrders(projectId: string) {
|
||||
priority: "high",
|
||||
work_order_type: "water_quality_complaint",
|
||||
location: "演示小区",
|
||||
scenario_code: "WQ-SECONDARY-SUPPLY-001",
|
||||
scenario_code: null,
|
||||
activity_detail: "投诉工单已创建。"
|
||||
},
|
||||
{
|
||||
@@ -1094,7 +1526,7 @@ function createSourceWorkOrders(projectId: string) {
|
||||
priority: "urgent",
|
||||
work_order_type: "burst_report",
|
||||
location: "演示路口",
|
||||
scenario_code: "BURST-REPORT-001",
|
||||
scenario_code: null,
|
||||
activity_detail: "爆管上报工单已创建。"
|
||||
}
|
||||
].map(({ activity_detail, ...event }) => ({
|
||||
@@ -1108,16 +1540,16 @@ function createSourceWorkOrders(projectId: string) {
|
||||
activity: [{
|
||||
sequence: 1,
|
||||
occurred_at: "2026-09-17T08:30:00Z",
|
||||
actor: "synthetic-generator",
|
||||
actor: "work-order-service",
|
||||
kind: "created",
|
||||
title: "工单创建",
|
||||
detail: activity_detail
|
||||
}],
|
||||
origin: "synthetic",
|
||||
origin: "system_data",
|
||||
created_at: "2026-09-17T08:30:00Z",
|
||||
updated_at: "2026-09-17T08:30:00Z",
|
||||
created_by: "synthetic-generator",
|
||||
updated_by: "synthetic-generator"
|
||||
created_by: "work-order-service",
|
||||
updated_by: "work-order-service"
|
||||
}));
|
||||
return [...appointments, ...events];
|
||||
}
|
||||
|
||||
@@ -254,7 +254,15 @@ for (const input of ["pointer", "keyboard"] as const) {
|
||||
});
|
||||
const trigger = dialog.getByRole("combobox");
|
||||
await expect(trigger).toBeFocused();
|
||||
const focusedBorder = await trigger.evaluate((element) => getComputedStyle(element).borderTopColor);
|
||||
const focusedBorder = await trigger.evaluate(async (element) => {
|
||||
// Flush focus styles before finding the CSS transition; focus can be set
|
||||
// while the border is still interpolating toward its final theme color.
|
||||
getComputedStyle(element).borderTopColor;
|
||||
await Promise.all(element.getAnimations().filter((animation) =>
|
||||
animation instanceof CSSTransition && animation.transitionProperty === "border-top-color"
|
||||
).map((animation) => animation.finished));
|
||||
return getComputedStyle(element).borderTopColor;
|
||||
});
|
||||
if (input === "keyboard") {
|
||||
await trigger.press("ArrowDown");
|
||||
await page.getByRole("option").filter({ has: page.getByText("凤阳(新版)", { exact: true }) }).focus();
|
||||
@@ -289,8 +297,8 @@ test("place search follows project configuration after switching", async ({ page
|
||||
await chooseProject(page, "凤阳(新版)");
|
||||
await dialog.getByRole("button", { name: "进入项目", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
const panel = page.getByRole("region", { name: "地址定位", exact: true });
|
||||
await page.getByRole("button", { name: "定位:查询地址或管网要素" }).click();
|
||||
const panel = page.getByRole("region", { name: "定位", exact: true });
|
||||
await expect(panel).toContainText("凤阳县 · 天地图地名搜索");
|
||||
await expect(panel).not.toContainText("奉贤区");
|
||||
await panel.getByLabel("查询地址").fill("府城镇");
|
||||
@@ -304,8 +312,8 @@ test("place search follows project configuration after switching", async ({ page
|
||||
test("unconfigured project disables place search", async ({ page }) => {
|
||||
await prepare(page, false, false);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "地址定位:查询地址并定位到地图" }).click();
|
||||
const panel = page.getByRole("region", { name: "地址定位", exact: true });
|
||||
await page.getByRole("button", { name: "定位:查询地址或管网要素" }).click();
|
||||
const panel = page.getByRole("region", { name: "定位", exact: true });
|
||||
await expect(panel).toContainText("当前项目暂未配置地名搜索");
|
||||
await expect(panel.getByRole("button", { name: "搜索地名" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
for (const [width, reduced] of [[1440, false], [375, false], [320, false], [1440, true]] as const) {
|
||||
test(`SCADA 全屏边缘告警通过按钮预览 ${width} ${reduced ? "静态" : "呼吸"}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: reduced ? "reduce" : "no-preference" });
|
||||
const mock = await mockInvestigationPresentation(page, []);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.clock.install();
|
||||
const atmosphere = page.getByTestId("scada-alert-atmosphere");
|
||||
await expect(atmosphere).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel", exact: true }).click();
|
||||
const panel = page.getByTestId("map-dev-panel");
|
||||
const preview = panel.getByRole("button", { name: "测试爆管告警效果", exact: true });
|
||||
await preview.click();
|
||||
await expect(atmosphere).toHaveAttribute("data-preview", "true");
|
||||
await expect(atmosphere).toContainText("爆管告警效果预览");
|
||||
const firstId = await atmosphere.getAttribute("data-alert-id");
|
||||
await preview.click();
|
||||
await expect(atmosphere).toHaveCount(1);
|
||||
await expect(atmosphere).not.toHaveAttribute("data-alert-id", firstId!);
|
||||
await expect(atmosphere).toHaveAttribute("data-intensity", "standard");
|
||||
if (width === 1440 && !reduced) {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
for (const [value, label] of [["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"]] as const) {
|
||||
await panel.getByRole("group", { name: "告警灯效强度" }).getByRole("button", { name: label, exact: true }).click();
|
||||
await expect(atmosphere).toHaveAttribute("data-intensity", value);
|
||||
await testInfo.attach(`intensity-${value}`, { body: await page.screenshot({ path: testInfo.outputPath(`intensity-${value}.png`) }), contentType: "image/png" });
|
||||
}
|
||||
await panel.getByRole("group", { name: "告警灯效强度" }).getByRole("button", { name: "标准", exact: true }).click();
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
}
|
||||
// The ambient overlay leaves ordinary workbench buttons and the map hit target usable.
|
||||
await panel.getByRole("button", { name: "关闭 Dev Panel" }).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(atmosphere).toBeVisible();
|
||||
await page.setViewportSize({ width, height: width < 640 ? 640 : 900 });
|
||||
expect(await atmosphere.boundingBox()).toEqual({ x: 0, y: 0, width, height: width < 640 ? 640 : 900 });
|
||||
await expect(atmosphere).toHaveCSS("pointer-events", "none");
|
||||
await expect(atmosphere).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
await expect(atmosphere.locator(".scada-alert-atmosphere-frame")).toHaveCSS("border-top-width", "1px");
|
||||
const status = atmosphere.getByRole("status");
|
||||
const material = await status.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return { background: style.backgroundColor, backdrop: style.backdropFilter, shadow: style.boxShadow };
|
||||
});
|
||||
expect(material.background).toMatch(/(?:,|\/)\s*0\.\d+\)$/);
|
||||
expect(material.backdrop).toMatch(/blur\((?!0px)[\d.]+px\)/);
|
||||
expect(material.shadow).not.toBe("none");
|
||||
const notice = (await status.boundingBox())!;
|
||||
const closeButton = atmosphere.getByRole("button", { name: "关闭告警灯效" });
|
||||
await expect(closeButton).toHaveText("关闭灯效");
|
||||
await expect(closeButton).toHaveCSS("border-top-width", "1px");
|
||||
const close = (await closeButton.boundingBox())!;
|
||||
expect(close.height).toBeGreaterThanOrEqual(36);
|
||||
expect(close.y - notice.y).toBeLessThanOrEqual(14);
|
||||
expect(notice.x + notice.width - close.x - close.width).toBeLessThanOrEqual(14);
|
||||
expect(await closeButton.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return element.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2));
|
||||
})).toBe(true);
|
||||
const light = atmosphere.locator(".scada-alert-atmosphere-light");
|
||||
await expect(light).toHaveCSS("animation-name", reduced ? "none" : "scada-alert-breathe");
|
||||
const samples = await light.evaluate(async (element) => {
|
||||
const values: number[] = [];
|
||||
for (let frame = 0; frame < 30; frame++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
values.push(Number(getComputedStyle(element).opacity));
|
||||
}
|
||||
return values;
|
||||
});
|
||||
if (reduced) expect(new Set(samples).size).toBe(1);
|
||||
else expect(Math.max(...samples) - Math.min(...samples)).toBeGreaterThan(0.01);
|
||||
expect(await page.evaluate(() => Boolean(document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)?.closest("[data-testid=scada-alert-atmosphere]")))).toBe(false);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await testInfo.attach(`scada-atmosphere-${width}-${reduced}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("scada-atmosphere.png") }), contentType: "image/png"
|
||||
});
|
||||
await page.clock.fastForward(60_000);
|
||||
await expect(atmosphere).toBeVisible();
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel", exact: true }).click();
|
||||
await panel.getByRole("button", { name: "模拟事件处理结束", exact: true }).click();
|
||||
await expect(atmosphere).toHaveCount(0);
|
||||
await preview.click();
|
||||
await atmosphere.getByRole("button", { name: "关闭告警灯效" }).click();
|
||||
await expect(atmosphere).toHaveCount(0);
|
||||
await expect(panel.getByRole("button", { name: "模拟事件处理结束", exact: true })).toBeDisabled();
|
||||
await panel.getByRole("button", { name: "关闭 Dev Panel" }).click();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
const peripheral = page.getByRole("complementary", { name: "外围系统" });
|
||||
await peripheral.getByRole("tab", { name: "SCADA 系统", exact: true }).click();
|
||||
await expect(peripheral.getByRole("button", { name: "测试爆管告警效果", exact: true })).toHaveCount(0);
|
||||
expect(mock.mutations).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockAgentApi } from "./support/mock-agent-api";
|
||||
import { mockWorkbenchContext } from "./support/mock-workbench-context";
|
||||
import { responsesFixture } from "./support/responses-fixture";
|
||||
|
||||
test.use({ timezoneId: "UTC" });
|
||||
|
||||
const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
|
||||
const block = {
|
||||
version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照",
|
||||
spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: {
|
||||
title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)",
|
||||
x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)),
|
||||
series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25],
|
||||
sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) },
|
||||
...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter",
|
||||
data: [...Array(10).fill(null), 34 + index * 0.4, 34.3 + index * 0.4, 34.6 + index * 0.4],
|
||||
sample_times: [...Array(10).fill(null), ...times.slice(10).map((time) => index % 2
|
||||
? `2026-06-${String(13 - index).padStart(2, "0")}T${time}:00+08:00`
|
||||
: new Date(`2026-06-${String(13 - index).padStart(2, "0")}T${time}:00+08:00`).toISOString().replace(".000Z", "Z"))] }))],
|
||||
source: "浏览器合成样例,非现场监测数据"
|
||||
} } } }
|
||||
};
|
||||
|
||||
for (const width of [1440, 375, 320]) {
|
||||
test(`SCADA comparison preserves seven daily point sets, timestamps and axis range at ${width}px`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await mockWorkbenchContext(page, undefined, undefined, {}, [], { runtimeMode: "backend" });
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/api/v1/responses", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
await route.fulfill({ headers: { "Access-Control-Allow-Origin": "*", "Content-Type": "text/event-stream" },
|
||||
body: responsesFixture([
|
||||
{ type: "start", messageId: "scada-week-answer" },
|
||||
{ type: "data-answer_ui", id: block.block_id, data: block },
|
||||
{ type: "data-final_answer", data: { content: "合成 SCADA 对照,待现场核验。", ui_block_ids: [block.block_id] } },
|
||||
{ type: "finish", finishReason: "stop" }
|
||||
]) });
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: width < 1024 ? "打开 Agent 面板" : /展开 Agent 助手面板/ }).click();
|
||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||
await panel.locator("textarea").fill("查看合成 SCADA 七日对照");
|
||||
await panel.getByRole("button", { name: "发送 Agent 指令" }).click();
|
||||
const ui = panel.locator(".answer-ui-chart");
|
||||
await expect(ui).toBeVisible();
|
||||
await expect(ui).toContainText("时间(UTC+8)");
|
||||
await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible();
|
||||
await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8);
|
||||
// Each historical day has exactly three unconnected SVG scatter symbols.
|
||||
for (const color of ["#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"]) {
|
||||
await expect(ui.locator(`svg path[fill="${color}"]`)).toHaveCount(3);
|
||||
}
|
||||
const axisValues = () => ui.locator("svg text").allTextContents().then((labels) => labels.filter((label) => /^-?\d+(\.\d+)?$/.test(label)).map(Number));
|
||||
expect((await axisValues()).every((value) => value > 0)).toBe(true);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await testInfo.attach(`scada-week-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-${width}.png`) }), contentType: "image/png" });
|
||||
await ui.locator('svg path[fill="#059669"]').last().hover({ position: { x: 4, y: 6 } });
|
||||
await expect(ui).toContainText("采样时间 2026-06-13 12:00:00+08:00");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await testInfo.attach(`scada-week-tooltip-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-tooltip-${width}.png`) }), contentType: "image/png" });
|
||||
const plot = ui.getByRole("group", { name: /按回车查看数据点/ });
|
||||
await plot.focus();
|
||||
await page.keyboard.press("End");
|
||||
const details = ui.getByRole("region", { name: "数据点详情" });
|
||||
await expect(details).toContainText("2026-06-14 12:00:00+08:00");
|
||||
await expect(details).toContainText("2026-06-13 12:00:00+08:00");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(details).toContainText("缺测");
|
||||
await ui.getByRole("button", { name: "关闭数据点详情" }).click();
|
||||
await ui.getByRole("tab", { name: "柱状", exact: true }).click();
|
||||
expect(await axisValues()).toContain(0);
|
||||
await ui.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(ui).toContainText("缺测");
|
||||
await expect(ui).toContainText("2026-06-13 12:00:00+08:00");
|
||||
expect(await ui.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
|
||||
// Use the product buttons to inspect both the floating and maximized layouts.
|
||||
await page.setViewportSize({ width, height: width < 640 ? 640 : 1000 });
|
||||
const search = ui.getByRole("textbox", { name: /搜索压力与前七天/ });
|
||||
await search.fill("2026-06-13");
|
||||
const expand = ui.getByRole("button", { name: "放大查看 压力与前七天同时刻对照(m)", exact: true });
|
||||
await expand.click();
|
||||
const floating = page.getByRole("dialog", { name: "压力与前七天同时刻对照(m)", exact: true });
|
||||
await expect(floating.getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(floating.getByRole("textbox", { name: /搜索压力与前七天/ })).toHaveValue("2026-06-13");
|
||||
await floating.getByRole("tab", { name: "复合", exact: true }).click();
|
||||
const drawing = floating.getByRole("img", { name: "压力与前七天同时刻对照(m)", exact: true });
|
||||
const instance = await drawing.locator(".echarts-for-react").getAttribute("_echarts_instance_");
|
||||
const restored = (await floating.boundingBox())!;
|
||||
await floating.getByRole("button", { name: "最大化窗口", exact: true }).click();
|
||||
await expect.poll(async () => (await floating.boundingBox())!.width).toBe(width - 16);
|
||||
await expect.poll(async () => (await floating.boundingBox())!.height).toBe((width < 640 ? 640 : 1000) - 16);
|
||||
await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("width")) - (await drawing.boundingBox())!.width)).toBeLessThan(1);
|
||||
await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("height")) - (await drawing.boundingBox())!.height)).toBeLessThan(1);
|
||||
await expect(drawing.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
|
||||
const content = floating.locator("[data-floating-window-content]");
|
||||
expect(await content.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
|
||||
const plotBox = (await drawing.boundingBox())!;
|
||||
const sidebar = (await floating.locator("aside").boundingBox())!;
|
||||
if (width < 640) expect(sidebar.y).toBeGreaterThanOrEqual(plotBox.y + plotBox.height);
|
||||
else expect(sidebar.x).toBeGreaterThanOrEqual(plotBox.x + plotBox.width);
|
||||
expect(plotBox.height).toBeGreaterThanOrEqual(220);
|
||||
await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click();
|
||||
await expect(ui.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||
await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click();
|
||||
await floating.locator('svg path[fill="#059669"]').last().hover({ position: { x: 4, y: 6 } });
|
||||
await expect(floating).toContainText("采样时间 2026-06-13 12:00:00+08:00");
|
||||
await floating.locator(".floating-window-heading").hover();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await testInfo.attach(`scada-week-fullscreen-${width}`, { body: await floating.screenshot({ path: testInfo.outputPath(`scada-week-fullscreen-${width}.png`) }), contentType: "image/png" });
|
||||
await floating.getByRole("button", { name: "还原窗口", exact: true }).click();
|
||||
await expect.poll(async () => (await floating.boundingBox())!.width).toBe(restored.width);
|
||||
await expect.poll(async () => (await floating.boundingBox())!.height).toBe(restored.height);
|
||||
await floating.getByRole("tab", { name: "数据", exact: true }).click();
|
||||
await expect(floating.getByRole("textbox", { name: /搜索压力与前七天/ })).toHaveValue("2026-06-13");
|
||||
await floating.getByRole("button", { name: "关闭窗口", exact: true }).click();
|
||||
await expect(floating).toHaveCount(0);
|
||||
await expect(expand).toBeFocused();
|
||||
await expect(search).toHaveValue("2026-06-13");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readRenderedContrast } from "./support/theme-colors";
|
||||
|
||||
for (const width of [1280, 375, 320]) {
|
||||
test(`SCADA 调查通知切换状态时保持高度与下方布局 ${width}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.route("**/scada-notice-preview", (route) => route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: '<!doctype html><html lang="zh-CN"><head><link rel="stylesheet" href="/src/styles.css"></head><body><main id="notice-reader" class="workbench-shadcn-surface bg-background text-foreground mx-auto w-[min(680px,calc(100%-1.5rem))] border p-3 sm:p-4"></main></body></html>'
|
||||
}));
|
||||
await page.goto("/scada-notice-preview");
|
||||
await page.evaluate(async () => {
|
||||
const refresh = await import("/@react-refresh");
|
||||
refresh.injectIntoGlobalHook(window);
|
||||
Object.assign(window, { $RefreshReg$: () => {}, $RefreshSig$: () => (type: unknown) => type });
|
||||
const entry = await (await fetch("/src/main.tsx")).text();
|
||||
const reactUrl = entry.match(/from "([^"\n]*\/react\.js[^"\n]*)"/)?.[1];
|
||||
const clientUrl = entry.match(/from "([^"\n]*\/react-dom_client\.js[^"\n]*)"/)?.[1];
|
||||
if (!reactUrl || !clientUrl) throw new Error("Vite React module paths unavailable");
|
||||
const reactModule = await import(reactUrl);
|
||||
const React = reactModule.default ?? reactModule;
|
||||
const clientModule = await import(clientUrl);
|
||||
const { createRoot } = clientModule.default ?? clientModule;
|
||||
const { ScadaMonitoringNotice } = await import("/src/features/workbench/peripheral-sample/scada-monitoring-notice.tsx");
|
||||
const { WorkbenchSection } = await import("/src/features/workbench/components/workbench-panel-ui.tsx");
|
||||
const result = {
|
||||
monitoring_id: "monitoring-1", period_end: "2026-07-16T00:00:00Z", status: "attention", task_id: "task-1",
|
||||
incident: { action: "raised" }
|
||||
};
|
||||
const states = [
|
||||
{ result: null, processing: false },
|
||||
{ result: null, processing: true },
|
||||
{ result: { ...result, status: "normal", task_id: null, incident: null }, processing: false },
|
||||
{ result, processing: false, animationId: "incident-1" },
|
||||
{ result: { ...result, status: "suspected_burst" }, processing: true, animationId: "incident-1" },
|
||||
{ result: { ...result, incident: { action: "ongoing" } }, processing: false, animationId: "incident-1" },
|
||||
{ result: { ...result, status: "normal", incident: { action: "resolved" } }, processing: false },
|
||||
{ result: { ...result, status: "insufficient_data", task_id: null, incident: null }, processing: false },
|
||||
{ result: null, processing: false }
|
||||
];
|
||||
const root = createRoot(document.getElementById("notice-reader"));
|
||||
const render = (index: number) => root.render(React.createElement(WorkbenchSection, {
|
||||
title: "SCADA 数据诊断", bodyClassName: "space-y-3"
|
||||
}, React.createElement(ScadaMonitoringNotice, {
|
||||
animationId: null, ...states[index],
|
||||
onOpenInvestigation: (taskId: string) => { document.body.dataset.openedTask = taskId; }
|
||||
}), React.createElement("div", { "data-testid": "following-controls" }, "演示开始时间 / 调频时间轴")));
|
||||
Object.assign(window, { setNoticeState: render });
|
||||
render(0);
|
||||
});
|
||||
const notice = page.getByRole("status", { name: "最新监测结果" });
|
||||
await expect(notice).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const initial = (await page.getByTestId("following-controls").boundingBox())!;
|
||||
const readSlots = () => notice.locator("[data-slot^=scada-notice-]").evaluateAll((elements) => elements.map((element) => {
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
return { slot: element.getAttribute("data-slot"), x, y, width, height };
|
||||
}));
|
||||
const initialSlots = await readSlots();
|
||||
const messages = ["监测待机", "正在监测", "监测正常", "发现指标异常", "发现疑似爆管", "异常持续跟踪", "异常已恢复", "监测数据不足", "监测待机"];
|
||||
const tones = ["neutral", "info", "success", "warning", "danger", "warning", "success", "neutral", "neutral"];
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
await page.evaluate((state) => (window as unknown as { setNoticeState: (index: number) => void }).setNoticeState(state), index);
|
||||
await expect(notice).toContainText(messages[index]);
|
||||
await expect(notice).toHaveAttribute("data-status-tone", tones[index]);
|
||||
expect((await notice.boundingBox())!.height).toBe(160);
|
||||
expect((await page.getByTestId("following-controls").boundingBox())!.y).toBe(initial.y);
|
||||
expect(await readSlots()).toEqual(initialSlots);
|
||||
expect(await notice.evaluate((element) => element.scrollWidth <= element.clientWidth && element.scrollHeight <= element.clientHeight)).toBe(true);
|
||||
expect(await readRenderedContrast(notice.locator("[data-slot=scada-notice-summary] p").last())).toBeGreaterThanOrEqual(4.5);
|
||||
if (index < 3 || index === 5) {
|
||||
await expect(notice.getByRole("button", { name: "查看调查" })).toHaveCount(index === 5 ? 1 : 0);
|
||||
await testInfo.attach(`notice-${width}-${index}`, { body: await notice.screenshot({ path: testInfo.outputPath(`notice-${index}.png`) }), contentType: "image/png" });
|
||||
}
|
||||
if (index === 3) {
|
||||
await expect(notice).toHaveAttribute("data-animation-state", "running");
|
||||
await notice.getByRole("button", { name: "查看调查" }).click();
|
||||
await expect(page.locator("body")).toHaveAttribute("data-opened-task", "task-1");
|
||||
await testInfo.attach(`notice-${width}`, { body: await notice.screenshot({ path: testInfo.outputPath("notice.png") }), contentType: "image/png" });
|
||||
await expect(notice).toHaveAttribute("data-animation-state", "idle");
|
||||
expect((await notice.boundingBox())!.height).toBe(160);
|
||||
}
|
||||
}
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.evaluate(() => (window as unknown as { setNoticeState: (index: number) => void }).setNoticeState(3));
|
||||
await expect(notice).toContainText("发现指标异常");
|
||||
await expect(notice).toHaveAttribute("data-animation-state", "idle");
|
||||
expect((await notice.boundingBox())!.height).toBe(160);
|
||||
expect((await page.getByTestId("following-controls").boundingBox())!.y).toBe(initial.y);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
for (const width of [1280, 375, 320]) {
|
||||
test(`SCADA 新告警进入通知中心并可从弹窗打开调查 ${width}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const task = investigationFixture(1, "SCADA 通知关联调查");
|
||||
task.workflow = "scada_anomaly";
|
||||
task.source = { kind: "external_system", external_id: "scada:test", external_revision: 1, event_kind: "scada_anomaly",
|
||||
replay_context: { replay_id: "97000000-0000-4000-8000-000000000001", knowledge_cutoff: task.created_at, batch_sequence: 1 } };
|
||||
await mockInvestigationPresentation(page, [task]);
|
||||
const alert = {
|
||||
id: "scada-incident-1", condition_id: `operational-task:${task.task_id}`,
|
||||
source: { kind: "scada_replay", replay_id: "replay-1", rule_id: "scada_diagnosis", batch_sequence: 1 },
|
||||
title: "SCADA 指标异常", description: "已启动调查,详细诊断见调查报告。",
|
||||
occurred_at: "2026-07-16T00:00:00Z", severity: "warning"
|
||||
};
|
||||
let alerts: typeof alert[] = [];
|
||||
let cursor = 10;
|
||||
await page.route("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot", (route) => route.fulfill({ json: {
|
||||
schema: "workbench-sync-snapshot@1", event_cursor: cursor, generated_at: task.created_at,
|
||||
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: "ready", generated_at: task.created_at, conditions: [], alerts, scenarios: [], latest_analysis: null, work_orders: [] },
|
||||
tasks: [Object.fromEntries(Object.entries(task).filter(([key]) =>
|
||||
!["latest_plan", "latest_report", "latest_progress", "evidence", "actions", "access_requests", "work_order_snapshot", "permissions", "questions"].includes(key)))],
|
||||
notifications: []
|
||||
} }));
|
||||
const emit = async (topic = "workbench.alert.changed") => {
|
||||
await page.evaluate((event) => {
|
||||
(window as unknown as { emitInvestigationTestEvent: (event: unknown) => void }).emitInvestigationTestEvent(event);
|
||||
}, { schema: "workbench-event@1", event_seq: ++cursor,
|
||||
event_id: `96000000-0000-4000-8000-${String(cursor).padStart(12, "0")}`, project_id: task.project_id,
|
||||
topic, aggregate_type: topic === "workbench.alert.changed" ? "task_alert" : "operational_task",
|
||||
aggregate_id: topic === "workbench.alert.changed" ? alert.id : task.task_id,
|
||||
aggregate_revision: cursor - 10, occurred_at: task.created_at, metadata: {} });
|
||||
};
|
||||
const initialSnapshot = page.waitForResponse("https://tjwater-api.invalid/api/v1/workbench/sync-snapshot");
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
expect((await (await initialSnapshot).json()).runtime.alerts).toEqual([]);
|
||||
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
|
||||
const menu = page.getByRole("button", { name: /查看待办与告警/ });
|
||||
await expect(menu).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项");
|
||||
// Open and close the panel first: announcements belong to the workbench, not its tab.
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await page.getByRole("button", { name: "关闭外围系统" }).click();
|
||||
alerts = [alert];
|
||||
await emit();
|
||||
const atmosphere = page.getByTestId("scada-alert-atmosphere");
|
||||
await expect(atmosphere).toBeVisible();
|
||||
await expect(atmosphere).toHaveAttribute("data-preview", "false");
|
||||
await expect(atmosphere).toContainText("爆管风险调查中");
|
||||
const popup = page.locator("[data-sonner-toast]").filter({ hasText: alert.title });
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(popup).toHaveAttribute("data-y-position", "top");
|
||||
await expect(popup).toHaveAttribute("data-x-position", "center");
|
||||
await expect(menu).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项");
|
||||
await menu.click();
|
||||
await expect(page.getByRole("menuitem", { name: `打开通知:${alert.title}` })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
const action = popup.getByRole("button", { name: "查看调查" });
|
||||
const summary = popup.locator("[data-slot=map-notice-summary]");
|
||||
const actionRow = popup.locator("[data-slot=map-notice-actions]");
|
||||
await expect(actionRow).toBeVisible();
|
||||
await expect(action).toHaveCSS("height", "36px");
|
||||
const layout = await popup.evaluate((element) => {
|
||||
const summary = element.querySelector("[data-slot=map-notice-summary]")!.getBoundingClientRect();
|
||||
const row = element.querySelector("[data-slot=map-notice-actions]")!.getBoundingClientRect();
|
||||
const action = element.querySelector("[data-slot=map-notice-actions] button")!.getBoundingClientRect();
|
||||
const close = element.querySelector('button[data-cancel="true"]')!.getBoundingClientRect();
|
||||
return { gap: row.top - summary.bottom, rightInset: row.right - action.right,
|
||||
closeOverlaps: close.bottom > action.top && close.left < action.right,
|
||||
overflows: element.scrollWidth > element.clientWidth };
|
||||
});
|
||||
expect(layout).toEqual({ gap: 0, rightInset: 12, closeOverlaps: false, overflows: false });
|
||||
await expect(summary).toContainText(alert.description);
|
||||
await expect.poll(() => action.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return element.contains(document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2));
|
||||
})).toBe(true);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await expect(popup).toHaveCSS("opacity", "1");
|
||||
expect(await popup.evaluate((element) => getComputedStyle(element).fontFamily)).toBe(
|
||||
await page.evaluate(() => getComputedStyle(document.body).fontFamily)
|
||||
);
|
||||
await testInfo.attach(`scada-notification-${width}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("notification.png") }), contentType: "image/png"
|
||||
});
|
||||
await action.click();
|
||||
await expect(page.getByRole("complementary", { name: "外围系统" })).toHaveCount(0);
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true }).getByRole("heading", { name: task.title })).toBeVisible();
|
||||
await expect(popup).toBeVisible();
|
||||
await menu.click();
|
||||
await page.getByRole("menuitem", { name: `打开通知:${alert.title}` }).click();
|
||||
await expect(page.getByRole("region", { name: "工况任务", exact: true }).getByRole("heading", { name: task.title })).toBeVisible();
|
||||
await emit();
|
||||
await expect(popup).toBeVisible();
|
||||
alerts = [];
|
||||
await emit();
|
||||
await expect(menu).toHaveAccessibleName("查看待办与告警,当前 0 条待处理事项");
|
||||
await expect(atmosphere).toBeVisible();
|
||||
task.lifecycle_status = "completed";
|
||||
task.status = "completed";
|
||||
task.active_run = null;
|
||||
task.revision++;
|
||||
await emit("operational.task.changed");
|
||||
await expect(atmosphere).toHaveCount(0);
|
||||
await expect(popup).toBeVisible();
|
||||
await popup.getByRole("button", { name: "关闭通知" }).click();
|
||||
await expect(popup).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
@@ -120,3 +120,42 @@ test("project loading failure can retry without retaining a loading animation",
|
||||
await expect(page.getByTestId("workbench-main-frame")).toBeVisible();
|
||||
} finally { retry.release(); }
|
||||
});
|
||||
|
||||
for (const width of [1440, 375]) {
|
||||
test(`project timeout shows readiness diagnosis and can retry at ${width}px`, async ({ page }, testInfo) => {
|
||||
test.setTimeout(40_000);
|
||||
let fail = true;
|
||||
const pending = gate();
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 900 : 740 });
|
||||
await mockWorkbenchContext(page);
|
||||
await mockAgentApi(page);
|
||||
await page.route("**/ready", (route) => route.fulfill({ status: 503, headers, json: {
|
||||
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
|
||||
} }));
|
||||
await page.route("**/api/v1/projects?**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback();
|
||||
if (fail) {
|
||||
await pending.ready;
|
||||
return route.abort().catch(() => undefined);
|
||||
}
|
||||
return route.fulfill({ headers, json: { items: [project], total: 1, limit: 1000, offset: 0 } });
|
||||
});
|
||||
try {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
const startup = page.getByRole("main", { name: "应用启动" });
|
||||
const error = startup.getByRole("alert");
|
||||
await expect(error).toContainText("读取授权项目超时(已等待 15 秒)", { timeout: 20_000 });
|
||||
await expect(error).toContainText("项目元数据库暂不可用");
|
||||
await expect(error).not.toContainText("signal timed out");
|
||||
await expect(startup.locator('[data-activity="loading"]')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await testInfo.attach(`project-timeout-${width}`, {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("project-timeout.png") }), contentType: "image/png"
|
||||
});
|
||||
fail = false;
|
||||
pending.release();
|
||||
await startup.getByRole("button", { name: "重新加载" }).click();
|
||||
await expect(page.getByTestId("workbench-main-frame")).toBeVisible();
|
||||
} finally { pending.release(); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,13 +91,21 @@ export async function mockInvestigationPresentation(page: Page, tasks: Operation
|
||||
await page.addInitScript(() => {
|
||||
const fetch = window.fetch.bind(window);
|
||||
const streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
||||
Object.assign(window, { emitInvestigationTestEvent: (event: unknown) => {
|
||||
let connectionAvailable = true;
|
||||
Object.assign(window, { setInvestigationTestConnection: (available: boolean) => {
|
||||
connectionAvailable = available;
|
||||
if (!available) {
|
||||
for (const stream of streams) stream.error(new TypeError("synthetic connection lost"));
|
||||
streams.clear();
|
||||
}
|
||||
}, emitInvestigationTestEvent: (event: unknown) => {
|
||||
const data = new TextEncoder().encode(`event: workbench_event\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
for (const stream of streams) stream.enqueue(data);
|
||||
} });
|
||||
window.fetch = async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!url.includes("/api/v1/workbench/events")) return fetch(input, init);
|
||||
if (!connectionAvailable) throw new TypeError("synthetic connection unavailable");
|
||||
let current: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({ start(controller) { current = controller; streams.add(controller); }, cancel() { streams.delete(current); } });
|
||||
init?.signal?.addEventListener("abort", () => { if (streams.delete(current)) current.close(); }, { once: true });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockInvestigationPresentation(page, []);
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
});
|
||||
|
||||
test("collapsed tasks leave the map below the visible panel interactive", async ({ page }, testInfo) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await expect(panel).toBeVisible();
|
||||
const box = (await panel.boundingBox())!;
|
||||
const point = { x: box.x + box.width / 2, y: box.y + box.height + 24 };
|
||||
expect(point.y).toBeLessThan(page.viewportSize()!.height - 20);
|
||||
await expect.poll(() => page.evaluate(({ x, y }) =>
|
||||
document.elementFromPoint(x, y)?.classList.contains("maplibregl-canvas") ?? false,
|
||||
point)).toBe(true);
|
||||
await page.locator(".maplibregl-canvas").evaluate((canvas) => {
|
||||
canvas.addEventListener("pointerdown", () => canvas.setAttribute("data-map-clicked", "true"), { once: true });
|
||||
});
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(page.locator(".maplibregl-canvas")).toHaveAttribute("data-map-clicked", "true");
|
||||
await testInfo.attach("collapsed-task-map-boundary", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("collapsed.png") }), contentType: "image/png"
|
||||
});
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 1440, height: 900 }, { width: 1024, height: 640 }]) {
|
||||
test(`expanded tasks stay inside their map boundary at ${viewport.width}x${viewport.height}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "工况任务", exact: true }).click();
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
const box = (await panel.boundingBox())!;
|
||||
const boundary = (await panel.locator("..").boundingBox())!;
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(boundary.y + boundary.height + 1);
|
||||
const timeline = panel.getByTestId("scheduled-condition-timeline");
|
||||
const scroll = timeline.locator(".scheduled-feed-scroll.flex-1");
|
||||
const scrollBox = (await scroll.boundingBox())!;
|
||||
expect(scrollBox.height).toBeGreaterThan(80);
|
||||
expect(scrollBox.y + scrollBox.height).toBeLessThanOrEqual(box.y + box.height - 12);
|
||||
await scroll.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
await expect(scroll.locator("button[aria-pressed]").last()).toBeInViewport();
|
||||
await testInfo.attach("expanded-task-map-boundary", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("expanded.png") }), contentType: "image/png"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("Dev stays above overlapping business panels and its menus stay usable", async ({ page }, testInfo) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
|
||||
const dev = page.getByTestId("map-dev-panel");
|
||||
await expect(dev).toBeVisible();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
await expect(page.getByRole("complementary", { name: "外围系统", exact: true })).toBeVisible();
|
||||
const heading = dev.getByRole("heading", { level: 2 });
|
||||
const box = (await heading.boundingBox())!;
|
||||
await expect.poll(() => page.evaluate(({ x, y }) =>
|
||||
Boolean(document.elementFromPoint(x, y)?.closest('[data-testid="map-dev-panel"]')),
|
||||
{ x: box.x + box.width / 2, y: box.y + box.height / 2 })).toBe(true);
|
||||
await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
|
||||
await dev.getByRole("combobox", { name: "业务源", exact: true }).click();
|
||||
await page.getByRole("option", { name: "管线", exact: true }).click();
|
||||
await expect(dev.getByRole("combobox", { name: "业务源", exact: true })).toContainText("管线");
|
||||
await testInfo.attach("dev-above-business-panels", {
|
||||
body: await page.screenshot({ path: testInfo.outputPath("dev.png") }), contentType: "image/png"
|
||||
});
|
||||
});
|
||||
|
||||
for (const mobile of [false, true]) {
|
||||
test(`opening peripheral systems suspends and restores ${mobile ? "mobile" : "desktop"} tasks`, async ({ page }) => {
|
||||
if (mobile) await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: mobile ? "打开工况任务" : "工况任务", exact: true }).click();
|
||||
const panel = page.getByRole("region", { name: "工况任务", exact: true });
|
||||
await panel.getByRole("button", { name: "展开工况任务" }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await page.getByRole("button", { name: "外围系统", exact: true }).click();
|
||||
const peripheral = page.getByRole("complementary", { name: "外围系统", exact: true });
|
||||
await expect(peripheral).toBeVisible();
|
||||
await expect(panel).toBeHidden();
|
||||
if (!mobile) {
|
||||
await expect(page.getByRole("button", { name: "工况任务", exact: true })).toHaveAttribute("aria-pressed", "false");
|
||||
}
|
||||
await peripheral.getByRole("button", { name: "关闭外围系统", exact: true }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.getByRole("button", { name: "收起工况任务" })).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user