fix: preserve SCADA comparison device coverage
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user