feat(scada): add manual monitoring timeline
This commit is contained in:
@@ -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"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user