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.
1557 lines
94 KiB
TypeScript
1557 lines
94 KiB
TypeScript
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];
|
||
}
|