Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9d04a370 | ||
|
|
eff59c5a7e | ||
|
|
d98fa359ae |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
@@ -25,6 +26,8 @@ let mockSessionState: {
|
|||||||
};
|
};
|
||||||
let mockPathname = "/network-simulation";
|
let mockPathname = "/network-simulation";
|
||||||
let mockProjectProviderBlocks = false;
|
let mockProjectProviderBlocks = false;
|
||||||
|
let capturedLogin: (() => Promise<unknown>) | null = null;
|
||||||
|
const replaceMock = jest.fn();
|
||||||
|
|
||||||
jest.mock("next-auth/react", () => ({
|
jest.mock("next-auth/react", () => ({
|
||||||
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
SessionProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
@@ -34,10 +37,20 @@ jest.mock("next-auth/react", () => ({
|
|||||||
|
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
usePathname: () => mockPathname,
|
usePathname: () => mockPathname,
|
||||||
|
useRouter: () => ({ replace: replaceMock }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
Refine: ({ children }: { children: ReactNode }) => children,
|
Refine: ({
|
||||||
|
children,
|
||||||
|
authProvider,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
authProvider: { login: (params: Record<string, never>) => Promise<unknown> };
|
||||||
|
}) => {
|
||||||
|
capturedLogin = () => authProvider.login({});
|
||||||
|
return children;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@refinedev/kbar", () => ({
|
jest.mock("@refinedev/kbar", () => ({
|
||||||
@@ -78,6 +91,9 @@ describe("RefineContext access authentication", () => {
|
|||||||
clearIntentionalLogout();
|
clearIntentionalLogout();
|
||||||
mockPathname = "/network-simulation";
|
mockPathname = "/network-simulation";
|
||||||
mockProjectProviderBlocks = false;
|
mockProjectProviderBlocks = false;
|
||||||
|
capturedLogin = null;
|
||||||
|
replaceMock.mockReset();
|
||||||
|
jest.mocked(signIn).mockClear();
|
||||||
mockSessionState = {
|
mockSessionState = {
|
||||||
data: {
|
data: {
|
||||||
accessToken: "expired-access-token",
|
accessToken: "expired-access-token",
|
||||||
@@ -217,6 +233,25 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts login with a safe post-authentication callback from the login route", async () => {
|
||||||
|
mockPathname = "/login";
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>登录页面</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedLogin).not.toBeNull();
|
||||||
|
await capturedLogin!();
|
||||||
|
|
||||||
|
expect(signIn).toHaveBeenCalledWith("keycloak", {
|
||||||
|
callbackUrl: "/",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not treat returning to a protected route after logout as session expiry", async () => {
|
it("does not treat returning to a protected route after logout as session expiry", async () => {
|
||||||
markIntentionalLogout();
|
markIntentionalLogout();
|
||||||
mockSessionState = { data: null, status: "unauthenticated" };
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
const authProvider: AuthProvider = {
|
const authProvider: AuthProvider = {
|
||||||
login: async () => {
|
login: async () => {
|
||||||
signIn("keycloak", {
|
signIn("keycloak", {
|
||||||
callbackUrl: to ? to.toString() : "/",
|
callbackUrl: to && to !== "/login" ? to.toString() : "/",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
});
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { useAccessStore } from "@/store/accessStore";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
import { RoutePermissionGuard } from "./RoutePermissionGuard";
|
||||||
|
|
||||||
|
const replaceMock = jest.fn();
|
||||||
|
let mockPathname = "/network-simulation";
|
||||||
|
|
||||||
jest.mock("next/navigation", () => ({
|
jest.mock("next/navigation", () => ({
|
||||||
usePathname: () => "/network-simulation",
|
usePathname: () => mockPathname,
|
||||||
|
useRouter: () => ({ replace: replaceMock }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/components/header", () => ({
|
jest.mock("@/components/header", () => ({
|
||||||
@@ -16,6 +20,8 @@ jest.mock("@/components/header", () => ({
|
|||||||
|
|
||||||
describe("RoutePermissionGuard", () => {
|
describe("RoutePermissionGuard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
replaceMock.mockReset();
|
||||||
|
mockPathname = "/network-simulation";
|
||||||
useAccessStore.setState({
|
useAccessStore.setState({
|
||||||
context: null,
|
context: null,
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -86,7 +92,7 @@ describe("RoutePermissionGuard", () => {
|
|||||||
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
|
expect(screen.getByTestId("access-header")).toHaveTextContent("project");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks authentication before the expired state effect runs", () => {
|
it("redirects an unauthenticated protected route to login", () => {
|
||||||
render(
|
render(
|
||||||
<RoutePermissionGuard authenticated={false}>
|
<RoutePermissionGuard authenticated={false}>
|
||||||
<div>受保护内容</div>
|
<div>受保护内容</div>
|
||||||
@@ -95,5 +101,20 @@ describe("RoutePermissionGuard", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
expect(screen.getByText("登录状态已失效")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
expect(screen.queryByText("无权访问此功能")).not.toBeInTheDocument();
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the login route to render while unauthenticated", () => {
|
||||||
|
mockPathname = "/login";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<RoutePermissionGuard authenticated={false}>
|
||||||
|
<div>登录页面</div>
|
||||||
|
</RoutePermissionGuard>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("登录页面")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("登录状态已失效")).not.toBeInTheDocument();
|
||||||
|
expect(replaceMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import { permissionForPath } from "@/lib/permissions";
|
import { permissionForPath } from "@/lib/permissions";
|
||||||
@@ -26,10 +26,22 @@ export const RoutePermissionGuard = ({
|
|||||||
accessDeniedReason?: "account" | "project" | null;
|
accessDeniedReason?: "account" | "project" | null;
|
||||||
}) => {
|
}) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const permissions = useAccessStore((state) => state.permissions);
|
const permissions = useAccessStore((state) => state.permissions);
|
||||||
const loading = useAccessStore((state) => state.loading);
|
const loading = useAccessStore((state) => state.loading);
|
||||||
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
const sessionExpired = useAuthStore((state) => state.sessionExpired);
|
||||||
const requiredPermission = permissionForPath(pathname);
|
const requiredPermission = permissionForPath(pathname);
|
||||||
|
const isLoginRoute = pathname === "/login";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticated && !isLoginRoute) {
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
}, [authenticated, isLoginRoute, router]);
|
||||||
|
|
||||||
|
if (isLoginRoute) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
if (!authenticated || sessionExpired) {
|
if (!authenticated || sessionExpired) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ describe("fetchHistoryData", () => {
|
|||||||
] });
|
] });
|
||||||
});
|
});
|
||||||
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
|
const result = await fetchHistoryData(targets, range, "scheme", "run-1");
|
||||||
expect(result.series).toHaveLength(4);
|
expect(result.series).toHaveLength(3);
|
||||||
expect(result.points[0].values).toEqual({ '["pipe","P-1","realtime_simulation"]': 36, '["junction","J-1","realtime_simulation"]': 30 });
|
expect(result.points[0].values).toEqual({ '["pipe","P-1","event_simulation"]': 36, '["junction","J-1","event_simulation"]': 30 });
|
||||||
expect(result.points[1].values['["pipe","P-1","analysis_simulation"]']).toBe(72);
|
expect(result.points[1].values['["pipe","P-1","event_simulation"]']).toBe(72);
|
||||||
expect(result.points[2].values['["pipe","P-1","realtime_simulation"]']).toBeCloseTo(43.2);
|
expect(result.points[2].values['["pipe","P-1","event_simulation"]']).toBeCloseTo(43.2);
|
||||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||||
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
for (const [, init] of jest.mocked(apiFetch).mock.calls) {
|
||||||
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
|
expect(JSON.parse(String(init?.body)).elements).toHaveLength(2);
|
||||||
@@ -317,7 +317,7 @@ describe("HistoryDataPanel requests", () => {
|
|||||||
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("plots native timestamps and explicit gaps with the event interval marked", async () => {
|
it("plots a single interpolated event curve with the event interval marked", async () => {
|
||||||
const series = {
|
const series = {
|
||||||
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
||||||
@@ -340,16 +340,21 @@ describe("HistoryDataPanel requests", () => {
|
|||||||
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
|
const option = jest.mocked(ReactECharts).mock.calls.at(-1)![0].option as any;
|
||||||
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
|
expect(option.xAxis).toMatchObject({ type: "time", min: Date.parse("2026-08-17T08:00:00Z"), max: Date.parse("2026-08-17T11:00:00Z") });
|
||||||
expect(option.series[0].connectNulls).toBe(false);
|
expect(option.series[0].connectNulls).toBe(false);
|
||||||
expect(option.series[0].data).toEqual([
|
expect(option.series).toHaveLength(1);
|
||||||
[Date.parse("2026-08-17T08:00:00Z"), 10],
|
expect(option.series[0].name).toBe("管道 P-1 (事件模拟) [m³/h]");
|
||||||
[Date.parse("2026-08-17T09:00:00Z"), null],
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T08:00:00Z"), 10]);
|
||||||
[Date.parse("2026-08-17T11:00:00Z"), 12],
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:00:00Z"), 8.4]);
|
||||||
]);
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T09:15:00Z"), 8]);
|
||||||
|
expect(option.series[0].data).toContainEqual([Date.parse("2026-08-17T11:00:00Z"), 12]);
|
||||||
expect(option.series[0].markArea.data[0]).toEqual([
|
expect(option.series[0].markArea.data[0]).toEqual([
|
||||||
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
|
{ name: "事件方案时段", xAxis: Date.parse("2026-08-17T09:00:00Z") },
|
||||||
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
|
{ xAxis: Date.parse("2026-08-17T10:00:00Z") },
|
||||||
]);
|
]);
|
||||||
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
|
expect(screen.queryByText(/当前时段暂无在线模拟数据/)).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "表格" }));
|
||||||
|
expect(await screen.findByRole("columnheader", { name: "管道 P-1 (事件模拟) [m³/h]" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
|
||||||
|
expect(screen.getByRole("gridcell", { name: "8.40" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("snaps off-step event context without changing the actual event marker", async () => {
|
it("snaps off-step event context without changing the actual event marker", async () => {
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ import {
|
|||||||
fetchElementHistory,
|
fetchElementHistory,
|
||||||
historySeriesKey,
|
historySeriesKey,
|
||||||
historySeriesLabel,
|
historySeriesLabel,
|
||||||
toTimeSeriesPoints,
|
|
||||||
TimeSeriesPoint,
|
TimeSeriesPoint,
|
||||||
} from "@/lib/elementHistory";
|
} from "@/lib/elementHistory";
|
||||||
|
import { mergeEventHistory, type EventHistoryOptions } from "@/lib/eventHistory";
|
||||||
import {
|
import {
|
||||||
type AnalysisHistoryContext,
|
type AnalysisHistoryContext,
|
||||||
getAnalysisHistoryContext,
|
getAnalysisHistoryContext,
|
||||||
@@ -106,6 +106,7 @@ export const fetchHistoryData = async (
|
|||||||
type: "realtime" | "scheme" | "none",
|
type: "realtime" | "scheme" | "none",
|
||||||
schemeRunId?: string,
|
schemeRunId?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
eventOptions?: EventHistoryOptions,
|
||||||
): Promise<ElementHistoryResult> => {
|
): Promise<ElementHistoryResult> => {
|
||||||
if (featureInfos.length === 0) {
|
if (featureInfos.length === 0) {
|
||||||
return { points: [], series: [] };
|
return { points: [], series: [] };
|
||||||
@@ -157,7 +158,9 @@ export const fetchHistoryData = async (
|
|||||||
...online.series,
|
...online.series,
|
||||||
...analysis.series.filter((item) => item.source === "analysis_simulation"),
|
...analysis.series.filter((item) => item.source === "analysis_simulation"),
|
||||||
];
|
];
|
||||||
return { series, points: toTimeSeriesPoints(series) };
|
return mergeEventHistory(series, range, eventOptions ?? {
|
||||||
|
eventRange: null, stepSeconds: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return fetchElementHistory(
|
return fetchElementHistory(
|
||||||
elements,
|
elements,
|
||||||
@@ -308,6 +311,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<PanelTab>(defaultTab);
|
||||||
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([]);
|
||||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||||
|
const [hasOnlineSimulationData, setHasOnlineSimulationData] = useState(false);
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
@@ -402,6 +406,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
requestControllerRef.current = null;
|
requestControllerRef.current = null;
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
|
setHasOnlineSimulationData(false);
|
||||||
setLoadingState("idle");
|
setLoadingState("idle");
|
||||||
setError(null);
|
setError(null);
|
||||||
return;
|
return;
|
||||||
@@ -425,6 +430,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
: type,
|
: type,
|
||||||
schemeContext?.simulationRunId,
|
schemeContext?.simulationRunId,
|
||||||
requestController.signal,
|
requestController.signal,
|
||||||
|
{ eventRange: eventRange ?? null, stepSeconds },
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
requestController.signal.aborted ||
|
requestController.signal.aborted ||
|
||||||
@@ -432,6 +438,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
) return;
|
) return;
|
||||||
setTimeSeries(result.points);
|
setTimeSeries(result.points);
|
||||||
setHistorySeries(result.series);
|
setHistorySeries(result.series);
|
||||||
|
setHasOnlineSimulationData(result.hasOnlineSimulationData ?? result.series.some(
|
||||||
|
(series) => series.source === "realtime_simulation"
|
||||||
|
&& series.points.some((point) => point.value !== null && Number.isFinite(point.value)),
|
||||||
|
));
|
||||||
setLoadingState("success");
|
setLoadingState("success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
@@ -443,13 +453,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
|
setHasOnlineSimulationData(false);
|
||||||
setError(err instanceof Error ? err.message : "未知错误");
|
setError(err instanceof Error ? err.message : "未知错误");
|
||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
requestedFeatureInfos, hasDevices, normalizedRange,
|
requestedFeatureInfos, hasDevices, normalizedRange,
|
||||||
schemeReady, schemeError, schemeContext, type,
|
schemeReady, schemeError, schemeContext, type, eventRange, stepSeconds,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1017,7 +1028,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
|
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
|
||||||
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
|
!hasOnlineSimulationData && (
|
||||||
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
||||||
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
|
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export type HistorySource =
|
|||||||
| "scada_raw"
|
| "scada_raw"
|
||||||
| "scada_cleaned"
|
| "scada_cleaned"
|
||||||
| "realtime_simulation"
|
| "realtime_simulation"
|
||||||
| "analysis_simulation";
|
| "analysis_simulation"
|
||||||
|
// 前端按事件时段拼接历史在线结果与方案结果。
|
||||||
|
| "event_simulation";
|
||||||
|
|
||||||
export interface ElementHistoryTarget {
|
export interface ElementHistoryTarget {
|
||||||
element_id: string;
|
element_id: string;
|
||||||
@@ -40,6 +42,8 @@ export interface TimeSeriesPoint {
|
|||||||
export interface ElementHistoryResult {
|
export interface ElementHistoryResult {
|
||||||
points: TimeSeriesPoint[];
|
points: TimeSeriesPoint[];
|
||||||
series: ElementHistorySeries[];
|
series: ElementHistorySeries[];
|
||||||
|
/** 事件序列合并后仍保留在线历史是否存在的信息。 */
|
||||||
|
hasOnlineSimulationData?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
||||||
@@ -47,6 +51,7 @@ const SOURCE_SUFFIX: Record<HistorySource, string> = {
|
|||||||
scada_cleaned: "clean",
|
scada_cleaned: "clean",
|
||||||
realtime_simulation: "sim",
|
realtime_simulation: "sim",
|
||||||
analysis_simulation: "scheme_sim",
|
analysis_simulation: "scheme_sim",
|
||||||
|
event_simulation: "event_sim",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const historySeriesKey = (series: ElementHistorySeries) => {
|
export const historySeriesKey = (series: ElementHistorySeries) => {
|
||||||
@@ -64,6 +69,8 @@ export const historySourceLabel = (source: HistorySource) => {
|
|||||||
return "实时模拟";
|
return "实时模拟";
|
||||||
case "analysis_simulation":
|
case "analysis_simulation":
|
||||||
return "方案模拟";
|
return "方案模拟";
|
||||||
|
case "event_simulation":
|
||||||
|
return "事件模拟";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { type ElementHistorySeries, historySeriesKey } from "./elementHistory";
|
||||||
|
import { mergeEventHistory } from "./eventHistory";
|
||||||
|
|
||||||
|
const time = (minutes: number) => new Date(Date.UTC(2026, 7, 17, 8, minutes)).toISOString();
|
||||||
|
const series = (
|
||||||
|
source: ElementHistorySeries["source"],
|
||||||
|
points: Array<[number, number | null]>,
|
||||||
|
overrides: Partial<ElementHistorySeries> = {},
|
||||||
|
): ElementHistorySeries => ({
|
||||||
|
element_id: "P-1", element_type: "pipe", device_id: null, metric: "flow",
|
||||||
|
source, source_unit: "m3/h", display_unit: "m³/h", unit_inferred: false,
|
||||||
|
points: points.map(([minutes, value]) => ({ time: time(minutes), value })),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
const range = { from: new Date(time(0)), to: new Date(time(120)) };
|
||||||
|
const options = { eventRange: { start: time(38), end: time(83) }, stepSeconds: 900 };
|
||||||
|
const values = (item: ElementHistorySeries) => Object.fromEntries(
|
||||||
|
item.points.map((point) => [point.time, point.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("mergeEventHistory", () => {
|
||||||
|
it("uses event values only in the event window and interpolates mismatched times and transitions", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [60, 999], [90, 100], [120, 100]]),
|
||||||
|
series("analysis_simulation", [[23, 999], [38, 80], [53, 60], [68, 40], [83, 80], [98, 999]]),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series).toHaveLength(1);
|
||||||
|
expect(result.series[0].source).toBe("event_simulation");
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(15)]).toBe(100);
|
||||||
|
expect(merged[time(38)]).toBe(80);
|
||||||
|
expect(merged[time(45)]).toBeCloseTo(70.6666667);
|
||||||
|
expect(merged[time(60)]).toBeCloseTo(50.6666667);
|
||||||
|
expect(merged[time(83)]).toBe(80);
|
||||||
|
expect(merged[time(90)]).toBe(100);
|
||||||
|
expect(merged[time(105)]).toBe(100);
|
||||||
|
expect(merged[time(23)]).toBeUndefined();
|
||||||
|
expect(merged[time(98)]).toBeUndefined();
|
||||||
|
expect(result.points.find((point) => point.timestamp === time(45))?.values[
|
||||||
|
historySeriesKey(result.series[0])
|
||||||
|
]).toBeCloseTo(70.6666667);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("interpolates across the seam when the first event sample starts after the event boundary", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[30, 100], [45, 999], [90, 100]]),
|
||||||
|
series("analysis_simulation", [[53, 54], [68, 24]]),
|
||||||
|
], range, options);
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(38)]).toBe(84);
|
||||||
|
expect(merged[time(45)]).toBe(70);
|
||||||
|
expect(merged[time(75)]).toBeCloseTo(48.181818);
|
||||||
|
expect(merged[time(0)]).toBeNull();
|
||||||
|
expect(merged[time(120)]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts different source units before merging and preserves SCADA samples", () => {
|
||||||
|
const observed = series("scada_raw", [[40, 12], [50, null]], { device_id: "F-1", source_unit: "L/s" });
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
observed,
|
||||||
|
series("realtime_simulation", [[0, 10], [90, 10]], { source_unit: "L/s" }),
|
||||||
|
series("analysis_simulation", [[38, 20], [83, 20]]),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series[0]).toBe(observed);
|
||||||
|
expect(result.points.find((point) => point.timestamp === time(40))?.values["F-1_raw"]).toBe(43.2);
|
||||||
|
const merged = values(result.series[1]);
|
||||||
|
expect(merged[time(0)]).toBe(36);
|
||||||
|
expect(merged[time(38)]).toBe(20);
|
||||||
|
expect(merged[time(90)]).toBe(36);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit missing samples and never interpolates through them", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, null], [90, 100], [120, 100]]),
|
||||||
|
series("analysis_simulation", [[38, 80], [53, null], [68, 40], [83, 80]]),
|
||||||
|
], range, options);
|
||||||
|
const merged = values(result.series[0]);
|
||||||
|
expect(merged[time(15)]).toBeNull();
|
||||||
|
expect(merged[time(30)]).toBeNull();
|
||||||
|
expect(merged[time(45)]).toBeNull();
|
||||||
|
expect(merged[time(53)]).toBeNull();
|
||||||
|
expect(merged[time(60)]).toBeNull();
|
||||||
|
expect(merged[time(75)]).toBeCloseTo(58.6666667);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps missing event results as gaps instead of inserting normal baseline values", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("realtime_simulation", [[0, 100], [30, 100], [45, 999], [90, 100], [120, 100]]),
|
||||||
|
], range, options);
|
||||||
|
expect(values(result.series[0])[time(45)]).toBeNull();
|
||||||
|
expect(values(result.series[0])[time(90)]).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps same-name pipes and junctions separate and supports analysis-only results", () => {
|
||||||
|
const result = mergeEventHistory([
|
||||||
|
series("analysis_simulation", [[38, 80], [83, 80]]),
|
||||||
|
series("analysis_simulation", [[38, 30], [83, 30]], {
|
||||||
|
element_type: "junction", metric: "pressure", source_unit: "m", display_unit: "m",
|
||||||
|
}),
|
||||||
|
], range, options);
|
||||||
|
expect(result.series).toHaveLength(2);
|
||||||
|
const point = result.points.find((item) => item.timestamp === time(45))!;
|
||||||
|
expect(Object.values(point.values)).toEqual([80, 30]);
|
||||||
|
expect(Object.values(result.points[0].values)).toEqual([null, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
type ElementHistoryResult,
|
||||||
|
type ElementHistorySeries,
|
||||||
|
toTimeSeriesPoints,
|
||||||
|
} from "./elementHistory";
|
||||||
|
import { FLOW_DISPLAY_UNIT, PRESSURE_DISPLAY_UNIT, toDisplayValue } from "@/utils/units";
|
||||||
|
|
||||||
|
export interface EventHistoryOptions {
|
||||||
|
eventRange: { start: string; end: string } | null;
|
||||||
|
stepSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sample = { time: number; value: number | null };
|
||||||
|
|
||||||
|
const samples = (series: ElementHistorySeries | undefined): Sample[] => {
|
||||||
|
const values = new Map<number, number | null>();
|
||||||
|
series?.points.forEach((point) => {
|
||||||
|
const time = Date.parse(point.time);
|
||||||
|
if (!Number.isFinite(time)) return;
|
||||||
|
const value = toDisplayValue(point.value, series.metric, series.source_unit);
|
||||||
|
values.set(time, value !== null && Number.isFinite(value) ? value : null);
|
||||||
|
});
|
||||||
|
return Array.from(values, ([time, value]) => ({ time, value }))
|
||||||
|
.sort((left, right) => left.time - right.time);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 拼接已保存的模拟结果;仅在相邻有效样本之间插值,保留显式缺测,不外推。 */
|
||||||
|
export const mergeEventHistory = (
|
||||||
|
series: ElementHistorySeries[],
|
||||||
|
range: { from: Date; to: Date },
|
||||||
|
{ eventRange, stepSeconds }: EventHistoryOptions,
|
||||||
|
): ElementHistoryResult => {
|
||||||
|
const groups = new Map<string, {
|
||||||
|
online?: ElementHistorySeries;
|
||||||
|
analysis?: ElementHistorySeries;
|
||||||
|
}>();
|
||||||
|
const observed = series.filter((item) => item.source.startsWith("scada_"));
|
||||||
|
series.forEach((item) => {
|
||||||
|
if (item.source !== "realtime_simulation" && item.source !== "analysis_simulation") return;
|
||||||
|
const key = JSON.stringify([item.element_type, item.element_id, item.metric]);
|
||||||
|
const group = groups.get(key) ?? {};
|
||||||
|
if (item.source === "realtime_simulation") group.online = item;
|
||||||
|
else group.analysis = item;
|
||||||
|
groups.set(key, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
const merged = Array.from(groups.values(), ({ online, analysis }) => {
|
||||||
|
const baseline = samples(online);
|
||||||
|
const event = samples(analysis);
|
||||||
|
const start = eventRange ? Date.parse(eventRange.start) : event[0]?.time ?? NaN;
|
||||||
|
const end = eventRange ? Date.parse(eventRange.end) : event.at(-1)?.time ?? NaN;
|
||||||
|
const hasEventRange = Number.isFinite(start) && Number.isFinite(end) && end >= start;
|
||||||
|
const anchors = new Map<number, number | null>();
|
||||||
|
baseline.forEach((point) => {
|
||||||
|
if (!hasEventRange || point.time < start || point.time > end) {
|
||||||
|
anchors.set(point.time, point.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
event.forEach((point) => {
|
||||||
|
if (hasEventRange && point.time >= start && point.time <= end) {
|
||||||
|
anchors.set(point.time, point.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hasEventRange && !event.some((point) => point.time >= start && point.time <= end)) {
|
||||||
|
anchors.set(start, null);
|
||||||
|
anchors.set(end, null);
|
||||||
|
}
|
||||||
|
const ordered = Array.from(anchors, ([time, value]) => ({ time, value }))
|
||||||
|
.sort((left, right) => left.time - right.time);
|
||||||
|
const from = range.from.getTime();
|
||||||
|
const to = range.to.getTime();
|
||||||
|
const times = new Set([
|
||||||
|
...baseline.map((point) => point.time),
|
||||||
|
...ordered.map((point) => point.time),
|
||||||
|
from, to,
|
||||||
|
]);
|
||||||
|
if (hasEventRange) {
|
||||||
|
times.add(start);
|
||||||
|
times.add(end);
|
||||||
|
}
|
||||||
|
const step = stepSeconds * 1000;
|
||||||
|
if (Number.isFinite(step) && step > 0 && ordered.length) {
|
||||||
|
const first = Math.max(from, ordered[0].time);
|
||||||
|
const last = Math.min(to, ordered[ordered.length - 1].time);
|
||||||
|
for (let time = Math.ceil(first / step) * step; time <= last; time += step) {
|
||||||
|
times.add(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
const points = Array.from(times).filter((time) => time >= from && time <= to)
|
||||||
|
.sort((left, right) => left - right).map((time) => {
|
||||||
|
while (index < ordered.length && ordered[index].time < time) index += 1;
|
||||||
|
const right = ordered[index];
|
||||||
|
const left = ordered[index - 1];
|
||||||
|
let value: number | null = null;
|
||||||
|
if (right?.time === time) value = right.value;
|
||||||
|
else if (left?.value != null && right?.value != null) {
|
||||||
|
value = left.value + (right.value - left.value)
|
||||||
|
* (time - left.time) / (right.time - left.time);
|
||||||
|
}
|
||||||
|
return { time: new Date(time).toISOString(), value };
|
||||||
|
});
|
||||||
|
const metadata = analysis ?? online!;
|
||||||
|
const unit = metadata.metric === "flow" ? FLOW_DISPLAY_UNIT : PRESSURE_DISPLAY_UNIT;
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
device_id: null,
|
||||||
|
source: "event_simulation" as const,
|
||||||
|
source_unit: unit,
|
||||||
|
display_unit: unit,
|
||||||
|
unit_inferred: Boolean(online?.unit_inferred || analysis?.unit_inferred),
|
||||||
|
points,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const result = [...observed, ...merged];
|
||||||
|
return {
|
||||||
|
series: result,
|
||||||
|
points: toTimeSeriesPoints(result),
|
||||||
|
hasOnlineSimulationData: series.some((item) =>
|
||||||
|
item.source === "realtime_simulation" && item.points.some((point) =>
|
||||||
|
point.value !== null && Number.isFinite(point.value)
|
||||||
|
&& Date.parse(point.time) >= range.from.getTime()
|
||||||
|
&& Date.parse(point.time) <= range.to.getTime(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user