fix: preserve SCADA comparison device coverage
This commit is contained in:
@@ -9,6 +9,7 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
|
|||||||
|
|
||||||
type PublicationChart = {
|
type PublicationChart = {
|
||||||
chart_type: string;
|
chart_type: string;
|
||||||
|
scada?: { role: "current" | "comparison"; window_start: string };
|
||||||
x_data: string[];
|
x_data: string[];
|
||||||
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
|
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);
|
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
|
||||||
else historical.push(...bases);
|
else historical.push(...bases);
|
||||||
}
|
}
|
||||||
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
|
const emptyScada = current.size === 0 && chart.scada?.role === "comparison";
|
||||||
const start = [...current][0];
|
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)
|
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
|
||||||
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
|
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
|
||||||
}
|
}
|
||||||
@@ -67,9 +72,25 @@ export function createViewSchemas(zod: typeof z = z, publication = false, valida
|
|||||||
const z = zod;
|
const z = zod;
|
||||||
|
|
||||||
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
|
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
|
const chartSchema = z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().max(200),
|
title: z.string().max(200),
|
||||||
|
scada: scadaChartSchema.optional(),
|
||||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||||
x_axis_name: z.string().max(128).optional(),
|
x_axis_name: z.string().max(128).optional(),
|
||||||
y_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
|
z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().max(128),
|
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(),
|
type: z.enum(["line", "bar", "scatter"]).optional(),
|
||||||
data: z.array(z.number().finite().nullable()).min(1).max(100),
|
data: z.array(z.number().finite().nullable()).min(1).max(100),
|
||||||
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
|
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export const investigationReportSchema = z
|
|||||||
stop_reason: text.nullish(),
|
stop_reason: text.nullish(),
|
||||||
data_gaps: z.array(dataGapSchema).nullish(),
|
data_gaps: z.array(dataGapSchema).nullish(),
|
||||||
diagram: investigationDiagramSchema.nullish(),
|
diagram: investigationDiagramSchema.nullish(),
|
||||||
ui_blocks: reportUiBlocksSchema.optional()
|
ui_blocks: reportUiBlocksSchema.optional(),
|
||||||
|
scada_device_ids: z.array(text).max(1000).nullish()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((report, ctx) => {
|
.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 selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
|
||||||
const multiple = group.items.length > 1;
|
const multiple = group.items.length > 1;
|
||||||
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
|
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="选择图表对象"
|
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"}>
|
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`}>
|
<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>
|
</div>
|
||||||
</aside>;
|
</aside>;
|
||||||
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
|
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">
|
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
|
||||||
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
|
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
|
||||||
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
<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}
|
{multiple ? objectButtons(true) : null}
|
||||||
</div>
|
</div>
|
||||||
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
|
</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={`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"}` : ""}`}>
|
<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}
|
{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", () => {
|
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", () => {
|
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 charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
|
||||||
const groups = groupInvestigationCharts(charts);
|
const groups = groupInvestigationCharts(charts);
|
||||||
|
|||||||
@@ -44,10 +44,19 @@ export function groupInvestigationCharts(charts: InvestigationChart[]): Investig
|
|||||||
const groups = new Map<string, InvestigationChartGroup>();
|
const groups = new Map<string, InvestigationChartGroup>();
|
||||||
for (const command of charts) {
|
for (const command of charts) {
|
||||||
const { chart } = command;
|
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 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 object = typed ? {
|
||||||
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
|
id: chart.series[0].device_id!,
|
||||||
const id = comparable ? JSON.stringify([
|
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,
|
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) => [
|
chart.series.filter((series) => series.type !== "scatter").map((series) => [
|
||||||
series.name.slice(object.id.length).trim(), series.sample_times ?? null
|
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