fix: preserve SCADA comparison device coverage

This commit is contained in:
2026-11-12 10:38:55 +08:00
parent a617aa95e9
commit 33f0b90a7d
6 changed files with 129 additions and 6 deletions
@@ -9,6 +9,7 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
type PublicationChart = {
chart_type: string;
scada?: { role: "current" | "comparison"; window_start: string };
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
@@ -56,8 +57,12 @@ export function chartPublicationError(chart: PublicationChart): string | null {
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const start = [...current][0];
const emptyScada = current.size === 0 && chart.scada?.role === "comparison";
if (current.size !== 1 && !emptyScada) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const windowStart = emptyScada ? Date.parse(chart.scada!.window_start) + 8 * 3600_000 : 0;
const midnight = Math.floor(windowStart / 86400_000) * 86400_000;
const firstSlot = midnight + seconds[0] * 1000;
const start = emptyScada ? firstSlot + (firstSlot < windowStart ? 86400_000 : 0) : [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
@@ -67,9 +72,25 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const scadaChartSchema = z.object({
role: z.enum(["current", "comparison"]),
metric: z.enum(["pressure", "flow"]),
unit: presentationIdentifierSchema,
window_start: createChartSampleTimeSchema(z),
window_end: createChartSampleTimeSchema(z),
value_field: z.enum(["monitored_value", "cleaned_value"]).optional(),
// Populated by Server after checking observations, never a display exemption.
gaps: z.array(z.object({
device_id: presentationIdentifierSchema,
day_offset: z.number().int().min(0).max(7),
missing_count: z.number().int().min(1).max(100),
reason: z.enum(["no_readings", "outside_frozen_scope", "outside_frozen_window", "no_scheduled_samples"])
}).strict()).max(8).optional()
}).strict();
const chartSchema = z
.object({
title: z.string().max(200),
scada: scadaChartSchema.optional(),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
@@ -79,6 +100,8 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida
z
.object({
name: z.string().max(128),
device_id: presentationIdentifierSchema.optional(),
day_offset: z.number().int().min(0).max(7).optional(),
type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
@@ -85,7 +85,8 @@ export const investigationReportSchema = z
stop_reason: text.nullish(),
data_gaps: z.array(dataGapSchema).nullish(),
diagram: investigationDiagramSchema.nullish(),
ui_blocks: reportUiBlocksSchema.optional()
ui_blocks: reportUiBlocksSchema.optional(),
scada_device_ids: z.array(text).max(1000).nullish()
})
.strict()
.superRefine((report, ctx) => {
@@ -34,6 +34,14 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
const multiple = group.items.length > 1;
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
const gaps = selected.command.chart.scada?.gaps ?? [];
const missingCurrent = gaps.filter((gap) => gap.day_offset === 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
const missingHistory = gaps.filter((gap) => gap.day_offset > 0 && gap.reason !== "no_scheduled_samples").reduce((sum, gap) => sum + gap.missing_count, 0);
const gapNotice = gaps.length ? <p role="status" aria-label="监测数据缺口" className="shrink-0 text-xs text-muted-foreground">
{[missingCurrent ? `当前缺测 ${missingCurrent} 点` : "", missingHistory ? `历史缺测 ${missingHistory} 点` : "",
gaps.some((gap) => gap.reason.startsWith("outside_frozen_")) ? "部分数据不在冻结范围内" : "",
gaps.some((gap) => gap.reason === "no_scheduled_samples") ? "窗口内无计划采样点" : ""].filter(Boolean).join(";")},已保留空槽。
</p> : null;
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
@@ -51,6 +59,7 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
</div>
</aside>;
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
{gapNotice}
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
@@ -62,6 +71,7 @@ export function InvestigationChartGroup({ group, reviewing, onCloseChart, refere
{multiple ? objectButtons(true) : null}
</div>
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
{gapNotice}
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
@@ -21,6 +21,32 @@ const legacyComparison = (id: string, metric = "压力", unit = "m", date = "202
};
describe("investigation comparison groups", () => {
it("groups all four bound devices even when titles differ and one device has no observations", () => {
const ids = ["486033", "542017", "541061", "524529"];
const charts = ids.map((id, index) => {
const command = comparison(id, "流量", "m3/h");
command.chart.title = `不同的标题 ${index}`;
command.chart.scada = { role: "comparison", metric: "flow", unit: "m3/h",
window_start: "2026-07-16T05:00:00+08:00", window_end: "2026-07-16T08:00:00+08:00" };
command.chart.series.forEach((series, offset) => {
series.device_id = id;
series.day_offset = offset;
series.name = offset ? "历史" : "当前";
if (id === "524529") { series.data = [null, null]; series.sample_times = [null, null]; }
});
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(ids);
expect(groups[0].id).toBe(groupInvestigationCharts([charts[3]])[0].id);
expect(groups[0].items[3].command).toBe(charts[3]);
charts[3].chart.scada!.unit = "m³/h";
charts[3].chart.scada!.window_start = "2026-07-15T21:00:00Z";
charts[3].chart.scada!.window_end = "2026-07-16T00:00:00Z";
expect(groupInvestigationCharts(charts)).toHaveLength(1);
});
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
const groups = groupInvestigationCharts(charts);
@@ -44,10 +44,19 @@ export function groupInvestigationCharts(charts: InvestigationChart[]): Investig
const groups = new Map<string, InvestigationChartGroup>();
for (const command of charts) {
const { chart } = command;
const scada = chart.scada;
const boundDevices = new Set(chart.series.map((series) => series.device_id));
const typed = scada?.role === "comparison" && boundDevices.size === 1 &&
!boundDevices.has(undefined) && chart.series.every((series) => series.day_offset !== undefined);
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
const id = comparable ? JSON.stringify([
const object = typed ? {
id: chart.series[0].device_id!,
title: `${scada.metric === "pressure" ? "压力" : "流量"}当前曲线与前七天同期对比(${scada.unit})`
} : match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && (typed || chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id));
const id = typed ? JSON.stringify(["scada-comparison", scada.metric, scada.unit.replace("³", "3").replace(/\s/g, "").toLowerCase(),
Date.parse(scada.window_start), Date.parse(scada.window_end), scada.value_field ?? "monitored_value", chart.x_data
]) : comparable ? JSON.stringify([
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
chart.series.filter((series) => series.type !== "scatter").map((series) => [
series.name.slice(object.id.length).trim(), series.sample_times ?? null
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test";
import { basename, resolve } from "node:path";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const ids = ["486033", "542017", "541061", "524529"];
const times = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const series = (id: string, day: number, missing = false) => ({
name: `${id} ${day ? `07-${String(16 - day).padStart(2, "0")}` : "当日"}`,
device_id: id, day_offset: day, type: day ? "scatter" : "line",
data: times.map((_, index) => missing || day > 0 && index < 10 ? null : 200 + index * 10),
sample_times: times.map((time, index) => missing || day > 0 && index < 10 ? null : `2026-07-${String(16 - day).padStart(2, "0")}T${time}:00+08:00`)
});
test("keeps four SCADA comparison objects and explains verified missing history", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
const task = investigationFixture(1, "SCADA展示覆盖核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const context = { metric: "flow", unit: "m3/h", window_start: "2026-07-16T05:00:00+08:00", window_end: "2026-07-16T08:00:00+08:00" };
await mock.emit(task, { action: "open_chart", chart_id: "flow-current", evidence_refs: ["synthetic:flow"], chart: {
title: "流量当前三小时趋势", chart_type: "line", x_data: times,
scada: { ...context, role: "current" }, series: ids.map((id) => series(id, 0))
} });
for (const id of ids) {
await mock.emit(task, { action: "open_chart", chart_id: `flow-${id}`, evidence_refs: ["synthetic:flow"], chart: {
title: `${id} 流量同期对比`, chart_type: "time_comparison", x_data: times,
scada: { ...context, role: "comparison", gaps: id === "524529" ? Array.from({ length: 7 }, (_, day) => ({
device_id: id, day_offset: day + 1, missing_count: 3, reason: day < 3 ? "no_readings" : "outside_frozen_window"
})) : [] },
series: Array.from({ length: 8 }, (_, day) => series(id, day, id === "524529" && day > 0))
} });
await expect(page.locator(`[data-presentation-scope="investigation"] [data-investigation-chart="flow-${id}"]`)).toHaveCount(1);
}
await page.getByRole("dialog", { name: "图表管理", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
const panel = page.getByLabel("调查实时展示", { exact: true });
await panel.getByRole("button", { name: /放大查看 流量当前曲线与前七天同期对比/ }).click();
const dialog = page.getByRole("dialog", { name: /流量当前曲线与前七天同期对比/ });
const objects = dialog.getByLabel("选择图表对象");
await expect(objects.getByRole("button")).toHaveCount(4);
await expect(objects).toContainText("4 个");
await objects.getByRole("button", { name: "切换对象 524529", exact: true }).click();
await expect(dialog.getByLabel("监测数据缺口")).toContainText("历史缺测 21 点");
await expect(dialog.getByLabel("监测数据缺口")).toContainText("部分数据不在冻结范围内");
await expect(dialog.getByRole("img", { name: "524529 流量同期对比", exact: true })).toBeVisible();
expect(await dialog.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("scada-four-objects-missing-history", {
body: await dialog.screenshot({ path: testInfo.outputPath("scada-four-objects.png") }), contentType: "image/png"
});
expect(mock.mutations).toEqual([]);
});