Files
next-tjwater-frontend/tests/browser/peripheral-sample-panel.e2e.ts
T
jiang aa9b12662e
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
fix(tasks): accept execution scope in task responses
Server responses now require execution_scope, but the strict frontend schema rejected it and prevented investigation and appointment loading. Declare it in the shared response schema and align task fixtures; regression coverage exercises summaries, details and both task categories.
2026-11-08 03:44:25 +08:00

1557 lines
94 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
import { mockWorkbenchEventStream } from "./support/mock-workbench-event-stream";
import { mockAgentApi } from "./support/mock-agent-api";
import { expectThemeColor, readRenderedContrast } from "./support/theme-colors";
test.describe("外围系统", () => {
test.beforeEach(async ({ page }) => {
await mockAgentApi(page);
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
contentType: "text/javascript",
body: `globalThis.__TJWATER_CONFIG__ = ${JSON.stringify({
TJWATER_AUTH_MODE: "disabled",
TJWATER_SERVER_API_BASE_URL: "http://server.test",
TJWATER_MAP_URL: "http://map.test/geoserver",
TJWATER_GEOSERVER_WORKSPACE: "tjwater_next"
})};`
})
);
await page.route("http://server.test/api/v1/projects**", (route) =>
route.fulfill({
json: {
items: [
{
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
gs_workspace: "tjwater_next",
status: "active"
}
],
total: 1,
limit: 1000,
offset: 0
}
})
);
await page.route("http://server.test/api/v1/scada-devices**", (route) =>
route.fulfill({ json: { items: [], total: 0, limit: 1000, offset: 0 } })
);
await page.route("http://map.test/**", (route) => route.fulfill({ status: 204 }));
});
test("外围面板只读取工单能力,不再请求监测回放与测试数据", async ({ page }, testInfo) => {
const requestedPaths: string[] = [];
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",
TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: "demo"
})};`
})
);
await page.route("http://server.test/api/v1/projects**", (route) =>
route.fulfill({
json: {
items: [
{
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
gs_workspace: "tjwater_next",
status: "active"
}
],
total: 1,
limit: 1000,
offset: 0
}
})
);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const url = new URL(route.request().url());
requestedPaths.push(url.pathname);
if (url.pathname.endsWith("/context"))
return route.fulfill({
json: {
protocol_version: "tjwater-peripheral-sample@1",
origin: "system_data",
project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001",
can_write: true,
scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: {
pending: ["processing"],
processing: ["pending_review"],
pending_review: ["processing", "completed"],
completed: []
}
}
});
if (url.pathname.endsWith("/work-orders"))
return route.fulfill({ json: { items: createSourceWorkOrders(projectId), next_cursor: null } });
return route.fulfill({
status: 404,
json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null }
});
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel).toBeVisible();
await expect(panel.getByText("工单管理", { exact: true })).toBeVisible();
await expect(panel.getByText("外围工单系统", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
await expect(panel.getByText("测试数据", { exact: true })).toHaveCount(0);
const appointmentList = panel.getByLabel("预约工单列表");
const complaintList = panel.getByLabel("投诉工单列表");
const burstList = panel.getByLabel("爆管上报工单列表");
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.getByText("工单报告", { exact: true })).toHaveCount(3);
await expect(appointmentList.getByText("暂无现场反馈", { exact: true })).toHaveCount(3);
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();
await appointmentDetails.locator(":scope > summary").click();
await expect(complaintList).toContainText("水质投诉:演示小区");
await expect(complaintList.getByRole("region", { name: "投诉工单信息" })).toContainText("发生时间");
await expect(complaintList.getByText("工单报告", { exact: true })).toHaveCount(0);
await expect(burstList).toContainText("爆管上报:演示路口");
await expect(burstList.getByRole("region", { name: "爆管上报工单信息" })).toContainText("发生时间");
await expect(complaintList).toHaveAttribute("open", "");
await complaintList.locator("summary").click();
await expect(complaintList).not.toHaveAttribute("open", "");
await expect(complaintList.getByText("水质投诉:演示小区")).toBeHidden();
await complaintList.locator("summary").click();
await expect(complaintList.getByText("水质投诉:演示小区")).toBeVisible();
expect(requestedPaths).toEqual(expect.arrayContaining([
"/api/sample/v1/context",
"/api/sample/v1/work-orders"
]));
expect(requestedPaths.some((path) => path.includes("/monitoring/") || path.endsWith("/test-data") || path.endsWith("/demo-data"))).toBe(false);
});
test("未配置时从顶栏进入且不暗连外围服务", async ({ page }) => {
const peripheralRequests: string[] = [];
page.on("request", (request) => {
if (request.url().includes("/api/sample/v1")) peripheralRequests.push(request.url());
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
await expect(page.getByRole("complementary", { name: "外围系统" })).toBeVisible();
await expect(
page
.getByRole("complementary", { name: "外围系统" })
.getByText("录入业务工单并跟踪处理反馈,事件调查依据实际查询和现场记录开展。", { exact: true })
.last()
).toBeVisible();
await expect(page.getByText(/当前不会连接外围工单服务/)).toBeVisible();
expect(peripheralRequests).toEqual([]);
});
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) =>
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"
})};`
})
);
await page.route("http://peripheral.test/api/sample/v1/**", (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/work-orders")) {
return route.fulfill({ json: { items: [], next_cursor: null } });
}
return route.fulfill({ status: 404, json: {} });
});
await page.route("http://server.test/api/v1/timeseries/scada-readings/latest?**", (route) => {
latestRequests.push(route.request().headers()["x-project-id"] ?? "");
return route.fulfill({
json: {
items: [
{
device_id: "PRESSURE-17",
device_type: "pressure",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: "2026-10-22T01:02:03Z",
monitored_value: 0.3172,
cleaned_value: 0.315
},
{
device_id: "FLOW-09",
device_type: "pipe_flow",
measurement_unit: "m³/h",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: null,
monitored_value: null,
cleaned_value: null
}
],
total: 2,
limit: 500,
offset: 0,
observed_count: 1,
latest_observed_at: "2026-10-22T01:02:03Z"
}
});
});
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",
threshold: 30, unit: "MPa", metric_name: "", lower: null, upper: null,
minimum_samples: 3, window_samples: 4, consecutive_samples: 1, recovery_samples: 2, minimum_devices: 1
}],
devices: [{ device_id: "PRESSURE-17", measurement_unit: "MPa", device_type: "pressure" }],
latest: [{ time: "2026-10-22T01:02:03Z", device_id: "PRESSURE-17", monitored_value: 0.3172,
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**", 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, 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; 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: 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-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 });
}
replay = { ...replay, revision: replay.revision + 1, status: command.action === "stop" ? "stopped" : "paused" };
return route.fulfill({ json: replay });
}
return route.fulfill({ json: replay });
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("tab", { name: "SCADA 系统" }).click();
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");
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 + 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 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);
await page.mouse.down();
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 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");
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();
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("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);
await testInfo.attach(`scada-tuning-${dialWidth}`, {
body: await page.screenshot({ path: testInfo.outputPath(`scada-tuning-${dialWidth}.png`) }), contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 1000 });
await testInfo.attach("scada-overview", {
body: await page.screenshot({ path: testInfo.outputPath("scada-overview.png") }), contentType: "image/png"
});
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 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 }) => {
await page.goto("/");
await page.locator(".maplibregl-canvas-container").evaluate((element) => {
const probeWindow = window as Window & { __mapPointerEvents?: string[] };
probeWindow.__mapPointerEvents = [];
for (const type of ["pointerdown", "wheel"] as const) {
element.addEventListener(type, () => probeWindow.__mapPointerEvents?.push(type), true);
}
});
const expectMapInteractionBlocked = async (panelName: string) => {
const panel = page.getByRole("complementary", { name: panelName });
const [panelBox, canvasBox] = await Promise.all([
panel.boundingBox(),
page.locator(".maplibregl-canvas").boundingBox()
]);
expect(panelBox).not.toBeNull();
expect(canvasBox).not.toBeNull();
const x = panelBox!.x - 24;
const y = panelBox!.y + 120;
expect(x).toBeGreaterThan(canvasBox!.x);
expect(y).toBeLessThan(canvasBox!.y + canvasBox!.height);
await expect
.poll(() =>
page.evaluate(
({ x: pointX, y: pointY }) =>
document.elementFromPoint(pointX, pointY)?.hasAttribute("data-map-interaction-shield"),
{ x, y }
)
)
.toBe(true);
await page.mouse.click(x, y);
await page.mouse.wheel(0, 120);
await expect
.poll(() =>
page.evaluate(
() => (window as Window & { __mapPointerEvents?: string[] }).__mapPointerEvents
)
)
.toEqual([]);
};
await page.getByRole("button", { name: "项目分析成果" }).click();
await expect(page.getByRole("complementary", { name: "项目分析成果" })).toBeVisible();
await expectMapInteractionBlocked("项目分析成果");
});
test("增量存储未供给时保留原有外围能力且不请求缺表资源", async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const managedRequests: string[] = [];
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"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", (route) => {
const path = new URL(route.request().url()).pathname;
if (path.includes("/work-orders") || path.includes("/test-data")) {
managedRequests.push(path);
return route.fulfill({ status: 503, json: {} });
}
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true,
scenarios: ["normal"], managed_resources: {
work_orders: "not_configured", test_data: "not_configured"
}, state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/tasks")) return route.fulfill({ json: { items: [], next_cursor: null } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: {
items: [], next_cursor: null, origin: "historical_replay", source_id: "tjwater_v2"
} });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: {
items: [], availability: "not_configured", reason: "告警源未配置",
origin: "historical_replay", source_id: "tjwater_v2"
} });
return route.fulfill({ status: 404, json: {} });
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统" }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel.getByText("工单服务未就绪", { exact: true })).toBeVisible();
await expect(panel.getByText("测试数据存储待供给", { exact: true })).toHaveCount(0);
await expect(panel.getByText("历史监测回放", { exact: true })).toHaveCount(0);
expect(managedRequests).toEqual([]);
});
test("创建编辑真实过程、刷新外部更新并软删除预约工单", async ({ page }, testInfo) => {
test.setTimeout(60_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
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" })}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const now = "2026-09-18T00:00:00Z";
if (path.endsWith("/context")) return route.fulfill({ json: { protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: { pending: ["processing"], processing: ["pending_review"], pending_review: ["processing", "completed"], completed: [] } } });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON();
writes.push(body);
const createdDetail = body.work_order_type === "water_quality_complaint"
? "投诉工单已创建。"
: body.work_order_type === "burst_report"
? "爆管上报工单已创建。"
: "预约工单已创建。";
const item = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001", project_id: projectId, revision: 1, origin: body.origin, created_at: now, updated_at: now, created_by: "tester", updated_by: "tester", activity: [{ sequence: 1, occurred_at: now, actor: "tester", kind: "created", title: "工单创建", detail: createdDetail }] };
delete item.request_id; workOrders = [item]; return route.fulfill({ status: 201, json: item });
}
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: 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", "occurred_at"]) delete next[key];
workOrders = [next];
return route.fulfill({ json: workOrders[0] });
}
if (path.includes("/work-orders/") && request.method() === "DELETE") { deleteRequests += 1; workOrders = []; return route.fulfill({ json: { resource_type: "work_order", resource_id: path.split("/").at(-1), revision: 3, deleted: true } }); }
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统" }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel.getByText("工单管理", { exact: true })).toBeVisible();
await expect(panel.getByText("外围工单系统", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
await expect(panel.getByLabel("预约工单列表")).toContainText("暂无预约工单");
await expect(panel.getByLabel("投诉工单列表")).toContainText("暂无投诉工单");
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: "说明", exact: true }).fill("验证外围过程结构同步");
await editor.getByRole("textbox", { name: "负责人", exact: true }).fill("测试班组甲");
await editor.getByRole("textbox", { name: "位置", exact: true }).fill("联调测试点西侧");
await editor.getByRole("textbox", { name: "场景编号" }).fill("APPOINTMENT-TEST-001");
// Portaled calendar and select menus keep the solid panel theme and keyboard focus.
await editor.getByRole("button", { name: "预约时间", exact: true }).click();
const calendar = page.getByRole("dialog", { name: "选择预约时间", exact: true });
await expect(calendar).toBeVisible();
await expectThemeColor(calendar.getByRole("textbox", { name: "年份", exact: true }), "color", "--ink");
await expect(calendar).toHaveCSS("background-color", "rgb(255, 255, 255)");
await calendar.getByRole("combobox", { name: "月份", exact: true }).click();
await expect(page.getByRole("listbox")).toHaveCSS("background-color", "rgb(255, 255, 255)");
await page.keyboard.press("Escape");
await expect(calendar.getByRole("combobox", { name: "月份", exact: true })).toBeFocused();
await testInfo.attach("peripheral-calendar", {
body: await page.screenshot({ path: testInfo.outputPath("peripheral-calendar.png") }),
contentType: "image/png"
});
await page.keyboard.press("Escape");
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();
expect(writes[0]).toMatchObject({
work_order_type: "general",
assignee: "测试班组甲",
location: "联调测试点西侧",
scenario_code: "APPOINTMENT-TEST-001",
feedback_requirements: ["回传现场处理结果", "上传照片"]
});
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"
});
console.log("work-order-title-contrast", await readRenderedContrast(editor.getByRole("textbox", { name: "标题" })));
for (const field of await editor.getByRole("textbox").all()) {
await expectThemeColor(field, "color", "--ink");
await expect(field).toHaveCSS("opacity", "1");
}
await page.setViewportSize({ width: 375, height: 900 });
await testInfo.attach("work-order-editor-text-narrow", {
body: await editor.screenshot({ path: testInfo.outputPath("work-order-editor-375.png") }), contentType: "image/png"
});
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.getByRole("textbox", { name: "标题" })).toBeFocused();
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: "负责人", 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();
expect(writes[1]).toMatchObject({
status: "awaiting_reply",
assignee: "测试班组乙",
location: "联调测试点东侧",
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" });
workOrders = [{ ...workOrders[0], title: "外部系统更新后的预约维修", revision: 3 }];
await panel.getByRole("button", { name: "刷新工单" }).click();
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeVisible();
await panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" }).click();
const refreshedEditor = panel.getByRole("region", { name: "编辑工单 外部系统更新后的预约维修" });
await expect(refreshedEditor.locator("xpath=ancestor::article")).toContainText("外部系统更新后的预约维修");
await expect(refreshedEditor.getByRole("textbox", { name: "负责人", exact: true })).toHaveValue("测试班组乙");
await expect(refreshedEditor.getByRole("textbox", { name: "位置", exact: true })).toHaveValue("联调测试点东侧");
await expect(refreshedEditor.getByRole("textbox", { name: "回执要求(每行一项)" })).toHaveValue("回传现场处理结果\n上传照片");
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 });
await refreshedEditor.locator("xpath=ancestor::article").evaluate((element) => element.scrollIntoView({ block: "start" }));
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-order-process-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`peripheral-work-order-edit-${width}.png`) }),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 780 });
await refreshedEditor.getByRole("button", { name: "关闭编辑表单" }).click();
await expect(panel.getByRole("button", { name: "编辑工单 外部系统更新后的预约维修" })).toBeFocused();
await expect(panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" })).toBeVisible();
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
const deletePrompt = panel.getByRole("group", { name: "确认删除工单 外部系统更新后的预约维修" });
await expect(deletePrompt).toBeVisible();
await expect(deletePrompt).toContainText("对应的工况任务将在后台同步后移出任务列表");
expect(deleteRequests).toBe(0);
await deletePrompt.getByRole("button", { name: "取消" }).click();
await expect(deletePrompt).toHaveCount(0);
expect(deleteRequests).toBe(0);
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
await page.keyboard.press("Escape");
await expect(deletePrompt).toHaveCount(0);
expect(deleteRequests).toBe(0);
await panel.getByRole("button", { name: "删除工单 外部系统更新后的预约维修" }).click();
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 780 });
await deletePrompt.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`peripheral-work-order-delete-prompt-${width}`, {
body: await page.screenshot(),
contentType: "image/png"
});
}
await deletePrompt.getByRole("button", { name: "确认删除" }).click();
expect(deleteRequests).toBe(1);
await expect(panel.getByText("外部系统更新后的预约维修", { exact: true })).toBeHidden();
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(`爆管上报:${resolvedAddress}`);
await expect(panel.getByLabel("爆管上报工单列表")).toContainText("爆管上报工单已创建。");
expect(workOrders[0]).toMatchObject({
work_order_type: "burst_report",
scenario_code: null,
origin: "system_data",
title: `爆管上报:${resolvedAddress}(GPS:${selectedGps})`,
location: `${resolvedAddress}(GPS:${selectedGps})`,
status: "open",
priority: "urgent"
});
});
for (const [kind, label] of [["water_quality_complaint", "水质投诉"], ["burst_report", "爆管上报"]]) {
test(`${label}业务录入使用真实来源且不附加演练场景`, async ({ page }) => {
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const orders: Record<string, unknown>[] = [];
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"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data", project_id: projectId,
user_id: "50000000-0000-4000-8000-000000000001", can_write: true, scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" }, state_transitions: {}
} });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
expect(body).toMatchObject({ work_order_type: kind, origin: "system_data", scenario_code: null });
const order = { ...body, work_order_id: "50000000-0000-4000-8000-000000000001",
project_id: projectId, revision: 1, created_at: "2026-10-29T01:00:00Z", updated_at: "2026-10-29T01:00:00Z",
created_by: "operator", updated_by: "operator", activity: [] };
delete order.request_id;
orders.push(order);
return route.fulfill({ status: 201, json: order });
}
if (path.endsWith("/work-orders")) return route.fulfill({ json: { items: orders, next_cursor: null } });
return route.fulfill({ status: 404, json: {} });
});
// Keep this check bounded to peripheral intake; investigation presentation has separate coverage.
await page.route("http://server.test/api/v1/operational-tasks", (route) =>
route.fulfill({ status: 503, json: { detail: "investigation unavailable in intake test" } }));
await page.goto("/");
await page.getByRole("button", { name: "外围系统", exact: true }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
if (kind === "burst_report") {
await panel.getByRole("combobox", { name: "事件类型" }).click();
await page.getByRole("option", { name: label, exact: true }).click();
}
await panel.getByRole("textbox", { name: "事件位置" }).fill("供水小区");
await panel.getByRole("textbox", { name: "现场描述" }).fill("用户上报异常,待实际数据核查");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel.getByRole("heading", { name: `${label}:供水小区`, exact: true })).toBeVisible();
await expect(panel.getByText("工单已创建,调查任务待触发", { exact: true })).toBeVisible();
await expect(panel.getByText("合成工单环境", { exact: true })).toHaveCount(0);
expect(orders).toHaveLength(1);
});
}
test("创建水质投诉工单后进入结构化工况任务并由用户打开 Agent", async ({ page }, testInfo) => {
test.setTimeout(90_000);
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const workOrderId = "50000000-0000-4000-8000-000000000099";
const agentRequests: Array<Record<string, unknown>> = [];
let workOrders: Record<string, unknown>[] = [];
const plan = {
schema: "execution-plan@1", title: "水质投诉调查计划", reason: "先完成四路并行核查,汇合后再分两路核实",
nodes: [
{ 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: [] },
{ id: "pressure", title: "比对压力波动", status: "completed", owner: "agent", detail: "泵房出水压力稳定", evidence_refs: [], action_refs: [] },
{ id: "hydraulic", title: "校核水力路径", status: "running", owner: "agent", detail: "追踪供水路径", evidence_refs: [], action_refs: [] },
{ id: "valve", title: "排查阀门操作", status: "pending", owner: "agent", detail: "等待水力路径结果", evidence_refs: [], action_refs: [] },
{ id: "maintenance", title: "核对运维记录", status: "waiting", owner: "human", detail: "等待近期冲洗记录", evidence_refs: [], action_refs: [] },
{ id: "stage-judgment", title: "阶段性判断", status: "completed", owner: "agent", detail: "疑似二次供水环节问题,待现场核验。", evidence_refs: ["e-1"], action_refs: [] },
{ id: "secondary", title: "核查二次供水", status: "running", owner: "agent", detail: "核对二次供水设施", evidence_refs: [], action_refs: [] },
{ id: "tank-record", title: "核对水箱记录", status: "waiting", owner: "human", detail: "等待水箱清洗记录", evidence_refs: [], action_refs: [] },
{ id: "sampling", title: "现场采样", status: "waiting", owner: "human", detail: "等待现场采样结果", evidence_refs: [], action_refs: [] },
{ id: "conclusion", title: "形成调查结论", status: "pending", owner: "agent", detail: "两路核验完成后汇总", evidence_refs: [], action_refs: [] }
],
edges: [
{ from: "accept", to: "upstream", condition: "上游监测" }, { from: "upstream", to: "upstream-review", condition: null }, { from: "upstream-review", to: "stage-judgment", condition: null },
{ from: "accept", to: "nearby", condition: "邻近投诉" }, { from: "nearby", to: "stage-judgment", condition: null },
{ from: "accept", to: "pressure", condition: "压力路径" }, { from: "pressure", to: "hydraulic", condition: null }, { from: "hydraulic", to: "valve", condition: null }, { from: "valve", to: "stage-judgment", condition: null },
{ from: "accept", to: "maintenance", condition: "运维记录" }, { from: "maintenance", to: "stage-judgment", condition: null },
{ from: "stage-judgment", to: "secondary", condition: "设施核验" }, { from: "secondary", to: "tank-record", condition: null }, { from: "tank-record", to: "conclusion", condition: null },
{ from: "stage-judgment", to: "sampling", condition: "现场采样" }, { from: "sampling", to: "conclusion", condition: null }
]
};
let operationalTask: Record<string, unknown> | null = null;
const summarizeTask = () => operationalTask
? Object.fromEntries(Object.entries(operationalTask).filter(([key]) => ![
"latest_plan", "latest_report", "evidence", "actions", "permissions", "work_order_snapshot"
].includes(key)))
: null;
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().includes("/api/v1/") &&
request.postData()
) {
agentRequests.push(request.postDataJSON() as Record<string, unknown>);
}
});
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_AGENT_URL: "http://127.0.0.1:8787",
TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: "http://peripheral.test"
})}`
}));
await page.route("http://peripheral.test/api/sample/v1/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (path.endsWith("/context")) return route.fulfill({ json: {
protocol_version: "tjwater-peripheral-sample@1", origin: "system_data",
project_id: projectId, user_id: "50000000-0000-4000-8000-000000000001",
can_write: true, scenarios: ["normal"],
managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/scenario")) return route.fulfill({ json: { scenario: "normal", revision: 1 } });
if (path.endsWith("/monitoring/points")) return route.fulfill({ json: { items: [], next_cursor: null, origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/monitoring/alerts")) return route.fulfill({ json: { items: [], availability: "not_configured", reason: "未配置", origin: "historical_replay", source_id: "test" } });
if (path.endsWith("/work-orders") && request.method() === "GET") return route.fulfill({ json: { items: workOrders, next_cursor: null } });
if (path.endsWith("/work-orders") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
const item = {
...body,
work_order_id: workOrderId,
project_id: projectId,
revision: 1,
origin: body.origin,
created_at: "2026-09-22T02:00:00Z",
updated_at: "2026-09-22T02:00:00Z",
created_by: "tester",
updated_by: "tester"
};
delete item.request_id;
workOrders = [item];
return route.fulfill({ status: 201, json: item });
}
if (path.endsWith("/test-data")) return route.fulfill({ json: { items: [], next_cursor: null } });
return route.fulfill({ status: 404, json: { code: "NOT_FOUND", message: "not found", effect: "none", request_id: null } });
});
await page.route("http://server.test/api/v1/operational-task-notifications**", (route) =>
route.fulfill({ json: { items: [], limit: 100, offset: 0, next_offset: null } })
);
await page.route("http://server.test/api/v1/operational-tasks/schedules", (route) =>
route.fulfill({ json: { items: [] } })
);
await page.route("http://server.test/api/v1/workbench/sync-snapshot", (route) =>
route.fulfill({ json: {
schema: "workbench-sync-snapshot@1",
event_cursor: 0,
runtime: {
availability: "not_configured", source: "none",
scenario_availability: "not_configured", artifact_availability: "not_configured",
schedule_availability: "not_configured", work_order_availability: "not_configured",
alert_availability: "not_configured", generated_at: "2026-09-23T08:30:00Z",
conditions: [], alerts: [], scenarios: [], latest_analysis: null, work_orders: []
},
tasks: summarizeTask() ? [summarizeTask()] : [],
notifications: [],
generated_at: "2026-09-23T08:30:00Z"
} })
);
await mockWorkbenchEventStream(page);
await page.route("http://server.test/api/v1/operational-tasks**", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks/schedules")) {
return route.fulfill({ json: { items: [] } });
}
if (request.method() === "POST" && url.pathname.endsWith("/operational-tasks")) {
const input = request.postDataJSON() as Record<string, unknown>;
operationalTask = {
execution_scope: "production",
project_id: projectId, task_id: "70000000-0000-4000-8000-000000000001",
task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal",
revision: 2, execution_generation: 1, execution_request_id: "71000000-0000-4000-8000-000000000001",
title: "水质投诉调查", summary: input.summary, source: input.source, trigger: input.trigger,
scheduled_for: null, agent_session_id: "browser-session-1", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1,
created_at: "2026-09-23T08:20:00Z", updated_at: "2026-09-23T08:25:00Z",
latest_plan: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:08:00Z", published_by: "agent", plan },
latest_report: { revision: 1, execution_generation: 1, source_session_id: "browser-session-1", published_at: "2026-09-23T01:09:00Z", published_by: "agent", report: { title: "水质投诉调查报告", current_conclusion: "疑似二次供水环节问题,待现场核验。", conclusion_boundary: "现场采样和水箱清洗记录尚未返回。", evidence_refs: ["e-1"], recommendations: ["安排现场采样"], pending_items: ["确认水箱清洗记录"] } },
evidence: [{ evidence_id: "e-1", kind: "synthetic", summary: "上游余氯处于正常范围", source_label: "外围合成场景", source_ref: "WQ-DEMO-01", observed_at: "2026-09-23T00:55:00Z", recorded_at: "2026-09-23T01:08:00Z", recorded_by: "agent", has_snapshot: false }],
actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false, can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false, can_decide_commands: false, can_execute_commands: false }
};
return route.fulfill({ status: 201, json: operationalTask });
}
if (request.method() === "GET" && url.pathname.endsWith("/operational-tasks")) {
const summary = summarizeTask();
return route.fulfill({ json: { items: summary ? [summary] : [], limit: 50, offset: 0, next_offset: null } });
}
if (request.method() === "GET" && operationalTask) return route.fulfill({ json: operationalTask });
return route.fulfill({ status: 404, json: {} });
});
await page.goto("/");
await page.getByRole("button", { name: "外围系统" }).click();
const panel = page.getByRole("complementary", { name: "外围系统" });
await panel.getByRole("button", { name: "投诉与爆管上报" }).click();
await panel.getByRole("textbox", { name: "事件位置" }).fill("演示小区");
await panel.getByRole("textbox", { name: "现场描述" }).fill("多户居民反映晨间用水有异味");
await panel.getByRole("button", { name: "创建事件工单" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeHidden();
const conditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
await expect(conditionPanel).toBeVisible();
await conditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(conditionPanel.getByRole("heading", { name: "水质投诉调查", exact: true })).toBeVisible();
const expectSavedPlan = async (taskPanel: typeof conditionPanel) => {
const diagram = taskPanel.getByRole("region", { name: "调查脉络", exact: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg g.node")).toHaveCount(plan.nodes.length);
await expect(diagram.locator("svg .flowchart-link")).toHaveCount(plan.edges.length);
await expect(diagram.getByLabel("图示状态说明")).toContainText("未标注状态");
await expect(diagram.locator("svg g.node").filter({ hasText: "形成调查结论" })).toBeVisible();
return diagram;
};
const diagram = await expectSavedPlan(conditionPanel);
await expect(conditionPanel.getByText(/并行处理/)).toHaveCount(0);
await expect(conditionPanel.getByRole("button", { name: "完整工程图" })).toHaveCount(0);
await expect(conditionPanel.getByText("节点与执行记录", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByLabel("计划版本", { exact: true })).toHaveCount(0);
await expect(diagram.locator("svg g.node").filter({ hasText: "核对水箱记录" })).toBeVisible();
const report = conditionPanel.getByRole("region", { name: "任务报告" });
await expect(report.getByText(/疑似二次供水环节问题,待现场核验/)).toBeVisible();
await expect(report.getByText("完整调查报告", { exact: true })).toHaveCount(0);
await expect(report.getByText(/结论边界/)).toBeVisible();
await expect(conditionPanel.getByText("TJWater operational task", { exact: true })).toHaveCount(0);
await expect(conditionPanel.getByText("browser-session-1", { exact: true })).toHaveCount(0);
await diagram.scrollIntoViewIfNeeded();
await expect(conditionPanel.getByText("前置工作与条件", { exact: true })).toHaveCount(0);
await diagram.scrollIntoViewIfNeeded();
await expect(page.getByText("已建立水质投诉调查", { exact: true })).toBeHidden({ timeout: 10_000 });
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("water-quality-condition-task-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-desktop.png") }),
contentType: "image/png"
});
await diagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded();
await testInfo.attach("water-quality-condition-task-second-fork-1280", {
body: await conditionPanel.screenshot({ path: testInfo.outputPath("execution-plan-second-fork-desktop.png") }),
contentType: "image/png"
});
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const responsiveConditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
if (!await responsiveConditionPanel.isVisible()) {
await page.getByRole("button", { name: "打开工况任务", exact: true }).click();
}
await expect(responsiveConditionPanel).toBeVisible();
const expandResponsiveTask = responsiveConditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandResponsiveTask.isVisible()) {
await expandResponsiveTask.click();
}
await responsiveConditionPanel.getByRole("button", { name: /调查任务/ }).click();
await expect(responsiveConditionPanel.getByRole("heading", { name: "水质投诉调查" })).toBeVisible();
const responsiveDiagram = await expectSavedPlan(responsiveConditionPanel);
const responsiveWorkflowStep = responsiveDiagram.locator("svg g.node").filter({ hasText: "校验上游水质监测" });
await responsiveWorkflowStep.scrollIntoViewIfNeeded();
await expect(responsiveWorkflowStep).toBeVisible();
await responsiveDiagram.scrollIntoViewIfNeeded();
await expect(responsiveConditionPanel.getByRole("button", { name: "放大流程图" })).toHaveCount(0);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`water-quality-condition-task-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-${width}.png`) }),
contentType: "image/png"
});
await responsiveDiagram.locator("svg g.node").filter({ hasText: "形成调查结论" }).scrollIntoViewIfNeeded();
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`water-quality-condition-task-second-fork-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`execution-plan-second-fork-${width}.png`) }),
contentType: "image/png"
});
}
await page.setViewportSize({ width: 1280, height: 720 });
const restoredConditionPanel = page.getByRole("region", { name: "工况任务", exact: true });
if (!await restoredConditionPanel.isVisible()) {
await page.getByRole("button", { name: "工况任务", exact: true }).click();
}
const agentCollaborationButton = restoredConditionPanel.getByRole("button", { name: "Agent 协作" });
if (!await agentCollaborationButton.isVisible()) {
const expandRestoredTask = restoredConditionPanel.getByRole("button", { name: "展开工况任务" });
if (await expandRestoredTask.isVisible()) await expandRestoredTask.click();
await restoredConditionPanel.getByRole("button", { name: /调查任务/ }).click();
}
await agentCollaborationButton.click();
await expect(page.getByRole("complementary", { name: "Agent 命令面板" })).toBeVisible();
expect(workOrders[0]).toMatchObject({
work_order_type: "water_quality_complaint",
scenario_code: null,
origin: "system_data",
status: "open"
});
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
await page.route("**/api/v1/sessions", (route) => route.fulfill({ json: { sessions: [{
id: "browser-session-1", title: "水质投诉调查", created_at: "2026-09-23T01:00:00Z", updated_at: "2026-09-23T01:00:00Z",
trigger: { subject_id: workOrderId, workflow: "water_quality_complaint", occurred_at: "2026-09-23T01:00:00Z" }
}] } }));
await page.reload();
await page.getByRole("button", { name: "工况任务", exact: true }).click();
const recovered = page.getByRole("region", { name: "工况任务", exact: true });
await recovered.getByRole("button", { name: "展开工况任务" }).click();
await recovered.getByRole("button", { name: /调查任务/ }).click();
const taskRow = recovered.getByRole("button", { name: /水质投诉调查/ }).first();
await taskRow.click();
await expectSavedPlan(recovered);
expect(agentRequests.filter((body) => "trigger_context" in body)).toHaveLength(0);
});
test("在 375px 视口内完整呈现面板", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 720 });
await page.goto("/");
const launcher = page.getByRole("button", { name: "外围系统" });
await launcher.focus();
await page.keyboard.press("Enter");
const panel = page.getByRole("complementary", { name: "外围系统" });
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(375);
});
});
function createDemoDataset(projectId: string) {
const points = Array.from({ length: 5 }, (_, index) => ({
point_id: `DEMO-${index + 1}`,
label: `虚拟压力测点 ${index + 1}`,
unit: "m",
status: "active",
origin: "synthetic"
}));
return {
generator_version: "water-demo@1",
seed: "water-demo-seed-20260917",
origin: "synthetic",
window: { start: "2026-09-17T00:00:00Z", end: "2026-09-17T12:00:00Z" },
project_id: projectId,
source_id: "peripheral-demo",
source_revision: "demo-r1",
points,
observations: Array.from({ length: 240 }, (_, index) => ({
record_id: `demo-reading-${index}`,
point_id: `DEMO-${(index % 5) + 1}`,
observed_at: `2026-09-17T${String(Math.floor(index / 20)).padStart(2, "0")}:${String((index % 4) * 15).padStart(2, "0")}:00Z`,
value: index === 0 ? 0 : index === 5 ? null : 31 + index / 100,
quality: index === 5 ? "missing" : "valid",
unit: "m"
})),
device_states: Array.from({ length: 9 }, (_, index) => ({
record_id: `60000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
asset_id: `VIRTUAL-${index + 1}`,
asset_type: "pressure_sensor",
observed_at: "2026-09-17T01:00:00Z",
state: index === 1 ? "offline" : "online"
})),
alerts: [
{
alert_id: "70000000-0000-4000-8000-000000000001",
asset_id: "VIRTUAL-2",
code: "communication_lost",
message: "演示通信中断",
occurred_at: "2026-09-17T02:00:00Z",
cleared_at: "2026-09-17T02:15:00Z"
},
{
alert_id: "70000000-0000-4000-8000-000000000002",
asset_id: "VIRTUAL-3",
code: "low_level",
message: "演示低水位",
occurred_at: "2026-09-17T05:00:00Z",
cleared_at: null
}
],
operations: Array.from({ length: 4 }, (_, index) => ({
record_id: `80000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
actor: "synthetic-generator",
asset_id: `VIRTUAL-${index + 1}`,
operation: ["pump_start", "pump_stop", "valve_open", "valve_close"][index],
note: "演示事件,不是控制指令",
occurred_at: `2026-09-17T0${index + 1}:30:00Z`
}))
};
}
function createSourceWorkOrders(projectId: string) {
const appointments = [
["通信核查", "核查监测点通信和数据上送状态", "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,
revision: 1,
title,
summary,
status,
priority,
assignee: null,
scheduled_at: "2026-09-17T08:00:00Z",
work_order_type: "general",
location: null,
scenario_code: null,
feedback_requirements: [],
activity: [],
origin: "system_data",
created_at: "2026-09-17T00:00:00Z",
updated_at: "2026-09-17T00:00:00Z",
created_by: "work-order-service",
updated_by: "work-order-service"
}));
const events = [
{
work_order_id: "50000000-0000-4000-8000-000000000004",
title: "水质投诉:演示小区",
summary: "居民反映晨间用水有异味",
priority: "high",
work_order_type: "water_quality_complaint",
location: "演示小区",
scenario_code: null,
activity_detail: "投诉工单已创建。"
},
{
work_order_id: "50000000-0000-4000-8000-000000000005",
title: "爆管上报:演示路口",
summary: "路面积水并伴随压力突降",
priority: "urgent",
work_order_type: "burst_report",
location: "演示路口",
scenario_code: null,
activity_detail: "爆管上报工单已创建。"
}
].map(({ activity_detail, ...event }) => ({
...event,
project_id: projectId,
revision: 1,
status: "open",
assignee: null,
scheduled_at: "2026-09-17T08:30:00Z",
feedback_requirements: [],
activity: [{
sequence: 1,
occurred_at: "2026-09-17T08:30:00Z",
actor: "work-order-service",
kind: "created",
title: "工单创建",
detail: activity_detail
}],
origin: "system_data",
created_at: "2026-09-17T08:30:00Z",
updated_at: "2026-09-17T08:30:00Z",
created_by: "work-order-service",
updated_by: "work-order-service"
}));
return [...appointments, ...events];
}