feat(scada): add manual monitoring timeline
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m12s

This commit is contained in:
2026-11-04 14:27:08 +08:00
parent 78481903d7
commit 0801e161ec
22 changed files with 1314 additions and 664 deletions
+124 -55
View File
@@ -162,9 +162,8 @@ test.describe("外围系统", () => {
expect(peripheralRequests).toEqual([]);
});
test("SCADA 系统按当前项目读取最新记录并设定模拟异常", async ({ page }, testInfo) => {
test("SCADA 调频时间轴按周期主动监测并隐藏设备列表", async ({ page }, testInfo) => {
const latestRequests: string[] = [];
const simulatedWrites: Array<Record<string, unknown>> = [];
const projectId = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
await page.route("**/runtime-config.js", (route) =>
route.fulfill({
@@ -185,24 +184,6 @@ test.describe("外围系统", () => {
scenarios: ["normal"], managed_resources: { work_orders: "available", test_data: "available" },
state_transitions: { pending: [], processing: [], pending_review: [], completed: [] }
} });
if (path.endsWith("/test-data") && request.method() === "GET") {
return route.fulfill({ json: { items: [], next_cursor: null } });
}
if (path.endsWith("/test-data") && request.method() === "POST") {
const body = request.postDataJSON() as Record<string, unknown>;
simulatedWrites.push(body);
return route.fulfill({ status: 201, json: {
...body,
record_id: "50000000-0000-4000-8000-000000000017",
project_id: projectId,
revision: 1,
origin: body.origin,
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
updated_by: "tester"
} });
}
if (path.endsWith("/work-orders")) {
return route.fulfill({ json: { items: [], next_cursor: null } });
}
@@ -243,52 +224,140 @@ test.describe("外围系统", () => {
});
});
const replayRequests: string[] = [];
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_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**", (route) => {
const request = route.request();
replayRequests.push(request.method());
const url = new URL(request.url());
if (url.pathname.endsWith("/capabilities")) return route.fulfill({ json: { available: true, can_write: true, reason: null, monitoring_interval_seconds: 900, burst_monitoring_ready: true, burst_monitoring_reason: null, current: replay } });
if (url.pathname.endsWith("/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 (url.pathname.endsWith("/commands")) {
const command = request.postDataJSON() as { action: string };
if (command.action === "step") {
const start = Date.parse(replay.start_time);
const step = replay.monitoring_interval_seconds * 1000;
const next = Math.min(Date.parse(replay.end_time), start + (Math.floor((Date.parse(replay.virtual_now) - start) / step) + 1) * step);
replay = { ...replay, revision: replay.revision + 1, status: "paused", virtual_now: new Date(next).toISOString(),
delivered_until: new Date(next).toISOString(), processed_until: new Date(next).toISOString(),
monitoring_count: replay.monitoring_count + 1, last_monitoring: {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: replay.virtual_now, period_end: new Date(next).toISOString(), knowledge_cutoff: new Date(next).toISOString(),
batch_sequence: 9, rule_version: "burst-monitor-v1", status: "normal",
detail: "本周期爆管相关指标均未命中", task_id: null,
pressure: {}, outlet_flow: {}, burst_detection: {}
} };
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.getByRole("table", { name: "SCADA 最新数据表" })).toBeVisible();
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
await expect(panel.getByText("0.3172 MPa", { exact: true })).toBeVisible();
await expect(panel.getByText("0.315 MPa", { exact: true })).toBeVisible();
await expect(panel.getByText("FLOW-09", { exact: true })).toBeVisible();
await expect(panel.getByText("无记录", { exact: true })).toBeVisible();
await expect(panel.getByLabel("SCADA 最新数据概览")).toContainText(/1\s*\/\s*2 个设备/);
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 expect(panel.getByPlaceholder("输入需要演练的异常值")).toHaveCount(0);
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(dial).toHaveCSS("user-select", "none");
expect(await dial.evaluate((element) => getComputedStyle(element, "::after").backgroundImage)).toContain("linear-gradient");
expect(await dial.locator(".scada-fm-scale").evaluate((element) => getComputedStyle(element).maskImage)).toContain("linear-gradient");
await expect(panel.locator(".scada-fm-needle")).toHaveCSS("background-color", "rgb(207, 69, 63)");
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);
await dial.focus();
await dial.press("Home");
await dial.press("ArrowRight");
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time) + 300000));
await dial.press("ArrowLeft");
await expect(dial).toHaveAttribute("aria-valuenow", String(Date.parse(replay.start_time)));
for (let step = 0; step < 12; step += 1) await dial.press("ArrowRight");
await expect.poll(() => dial.getByText("09:00", { exact: true }).evaluate((element) =>
new DOMMatrixReadOnly(getComputedStyle(element).transform).a
)).toBeCloseTo(1.55, 2);
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);
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();
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");
expect(Number(await dial.getAttribute("aria-valuenow")) % 300000).toBe(0);
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("09: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("textbox", { name: "筛选 SCADA 设备" }).fill("pressure");
await expect(panel.getByText("PRESSURE-17", { exact: true })).toBeVisible();
await expect(panel.getByText("FLOW-09", { exact: true })).toHaveCount(0);
await panel.getByRole("textbox", { name: "筛选 SCADA 设备" }).fill("");
await panel.getByRole("button", { name: "设定读数" }).click();
for (const width of [1280, 375, 320]) {
await page.setViewportSize({ width, height: 900 });
await panel.getByPlaceholder("输入需要演练的异常值").scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`scada-peripheral-anomaly-${width}`, {
body: await page.screenshot({ path: testInfo.outputPath(`scada-${width}.png`) }),
contentType: "image/png"
});
}
await panel.getByPlaceholder("输入需要演练的异常值").fill("0.08");
await panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间").fill("压力突降");
await expectThemeColor(panel.getByPlaceholder("输入需要演练的异常值"), "color", "--ink");
await expectThemeColor(panel.getByPlaceholder("例如:压力突降,偏离近一小时稳定区间"), "color", "--ink");
await panel.getByRole("button", { name: "保存模拟读数", exact: true }).click();
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("PRESSURE-17");
await expect(panel.getByLabel("SCADA 模拟异常列表")).toContainText("0.08 MPa");
expect(simulatedWrites[0]).toMatchObject({
point_id: "PRESSURE-17",
value: 0.08,
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降"
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"
});
});