Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67f9542cd |
@@ -4,6 +4,8 @@ import dayjs from "dayjs";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { getAnalysisScheme, toAnalysisSchemeRecord } from "@/lib/analysisRuns";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
@@ -29,6 +31,11 @@ jest.mock("@mui/x-date-pickers", () => ({
|
||||
LocalizationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock("echarts-for-react", () => jest.fn(() => null));
|
||||
jest.mock("./useTimelineTimeConfig", () => ({
|
||||
useTimelineTimeConfig: () => ({
|
||||
durationMinutes: 1440, stepMinutes: 15, properties: { "HYDRAULIC TIMESTEP": "0:15" },
|
||||
}),
|
||||
}));
|
||||
|
||||
const jsonResponse = (payload: unknown, status = 200) =>
|
||||
({
|
||||
@@ -177,6 +184,8 @@ describe("HistoryDataPanel requests", () => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] }));
|
||||
jest.mocked(getAnalysisScheme).mockReset().mockResolvedValue(eventRun());
|
||||
useAccessStore.setState({ permissions: ["simulation.run"], loading: false });
|
||||
useProjectStore.getState().setCurrentProject("project-1", "network-1");
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
@@ -302,6 +311,7 @@ describe("HistoryDataPanel requests", () => {
|
||||
render(React.createElement(HistoryDataPanel, props));
|
||||
await screen.findByText(/无法读取事件方案/);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "刷新" }));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
expect(getAnalysisScheme).toHaveBeenCalledTimes(2);
|
||||
@@ -341,4 +351,58 @@ describe("HistoryDataPanel requests", () => {
|
||||
]);
|
||||
expect(option.series[1].data).toEqual([[Date.parse("2026-08-17T09:15:00Z"), 8]]);
|
||||
});
|
||||
|
||||
it("snaps off-step event context without changing the actual event marker", async () => {
|
||||
jest.mocked(getAnalysisScheme).mockResolvedValue(eventRun("run-1", "2026-07-30T05:38:00Z"));
|
||||
render(React.createElement(HistoryDataPanel, props));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[0][1]?.body))).toMatchObject({
|
||||
start_time: "2026-07-30T04:30:00.000Z", end_time: "2026-07-30T07:45:00.000Z",
|
||||
});
|
||||
expect(screen.getByText(/事件方案时段:2026-07-30 13:38/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("runs online simulation once, shows progress, then refreshes both history sources", async () => {
|
||||
let finishSimulation!: (value: Response) => void;
|
||||
jest.mocked(apiFetch).mockImplementation(async (url) => {
|
||||
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
|
||||
if (String(url).endsWith("/simulation-runs")) return new Promise((resolve) => { finishSimulation = resolve; });
|
||||
return jsonResponse({ series: [] });
|
||||
});
|
||||
render(React.createElement(HistoryDataPanel, props));
|
||||
const button = screen.getByRole("button", { name: "在线模拟" });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
fireEvent.click(button);
|
||||
await screen.findByText("正在计算在线模拟数据,完成后将自动刷新图表。");
|
||||
expect(screen.getByRole("button", { name: "模拟中…" })).toBeDisabled();
|
||||
await waitFor(() => expect(finishSimulation).toBeDefined());
|
||||
fireEvent.click(button);
|
||||
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
|
||||
await act(async () => { finishSimulation(jsonResponse({ status: "success" })); });
|
||||
await waitFor(() => expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(4));
|
||||
expect(screen.getByRole("button", { name: "在线模拟" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("disables the write action for a viewer while allowing history reads", async () => {
|
||||
useAccessStore.setState({ permissions: ["simulation.view"] });
|
||||
render(React.createElement(HistoryDataPanel, props));
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||
expect(screen.getByRole("button", { name: "在线模拟" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "刷新" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("shows a simulation failure without retrying or claiming data was refreshed", async () => {
|
||||
jest.mocked(apiFetch).mockImplementation(async (url) => {
|
||||
if (String(url).endsWith("/network-options/time")) return jsonResponse({ "HYDRAULIC TIMESTEP": "0:15" });
|
||||
if (String(url).endsWith("/simulation-runs")) return jsonResponse({ detail: "计算服务不可用" }, 503);
|
||||
return jsonResponse({ series: [] });
|
||||
});
|
||||
render(React.createElement(HistoryDataPanel, props));
|
||||
const button = screen.getByRole("button", { name: "在线模拟" });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("计算服务不可用");
|
||||
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/simulation-runs"))).toHaveLength(1);
|
||||
expect(jest.mocked(apiFetch).mock.calls.filter(([url]) => String(url).endsWith("/element-history/query"))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Close, Refresh, ShowChart, TableChart } from "@mui/icons-material";
|
||||
import { Close, PlayArrow, Refresh, ShowChart, TableChart } from "@mui/icons-material";
|
||||
import { DataGrid, GridColDef } from "@mui/x-data-grid";
|
||||
import { zhCN } from "@mui/x-data-grid/locales";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
@@ -49,6 +49,15 @@ import {
|
||||
getAnalysisHistoryContext,
|
||||
getAnalysisScheme,
|
||||
} from "@/lib/analysisRuns";
|
||||
import {
|
||||
alignSimulationRange,
|
||||
parseHydraulicStepSeconds,
|
||||
runOnlineSimulation,
|
||||
} from "@/lib/onlineSimulation";
|
||||
import { permissionCodes } from "@/lib/permissions";
|
||||
import { useAccessStore } from "@/store/accessStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { useTimelineTimeConfig } from "./useTimelineTimeConfig";
|
||||
import {
|
||||
FLOW_DISPLAY_UNIT,
|
||||
PRESSURE_DISPLAY_UNIT,
|
||||
@@ -225,6 +234,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
runId,
|
||||
onClose,
|
||||
}) => {
|
||||
const timeConfig = useTimelineTimeConfig();
|
||||
const stepSeconds = parseHydraulicStepSeconds(timeConfig.properties["HYDRAULIC TIMESTEP"])
|
||||
?? timeConfig.stepMinutes * 60;
|
||||
const canRunSimulation = useAccessStore((state) =>
|
||||
state.permissions.includes(permissionCodes.simulationRun),
|
||||
);
|
||||
const projectId = useProjectStore((state) => state.currentProjectId);
|
||||
const [schemeState, setSchemeState] = useState<{
|
||||
runId: string;
|
||||
context?: AnalysisHistoryContext;
|
||||
@@ -246,7 +262,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const now = dayjs();
|
||||
const start = start_time ? dayjs(start_time) : null;
|
||||
const end = end_time ? dayjs(end_time) : null;
|
||||
return {
|
||||
const range = {
|
||||
from: start?.isValid()
|
||||
? start
|
||||
: eventRange
|
||||
@@ -258,7 +274,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
? dayjs(eventRange.end).add(1, "hour")
|
||||
: now,
|
||||
};
|
||||
}, [start_time, end_time, eventRange]);
|
||||
const aligned = alignSimulationRange({ from: range.from.toDate(), to: range.to.toDate() }, stepSeconds);
|
||||
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
|
||||
}, [start_time, end_time, eventRange, stepSeconds]);
|
||||
const [selectedRange, setSelectedRange] = useState<{
|
||||
key: string;
|
||||
from: Dayjs;
|
||||
@@ -273,10 +291,14 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const to = rangeForContext && (
|
||||
rangeForContext.endTime === end_time || !end_time || !dayjs(end_time).isValid()
|
||||
) ? rangeForContext.to : defaultRange.to;
|
||||
const setFrom = (value: Dayjs) =>
|
||||
setSelectedRange({ key: rangeKey, from: value, to, startTime: start_time, endTime: end_time });
|
||||
const setTo = (value: Dayjs) =>
|
||||
setSelectedRange({ key: rangeKey, from, to: value, startTime: start_time, endTime: end_time });
|
||||
const setFrom = (value: Dayjs) => {
|
||||
const range = alignSimulationRange({ from: value.toDate(), to: to.toDate() }, stepSeconds);
|
||||
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
|
||||
};
|
||||
const setTo = (value: Dayjs) => {
|
||||
const range = alignSimulationRange({ from: from.toDate(), to: value.toDate() }, stepSeconds);
|
||||
setSelectedRange({ key: rangeKey, from: dayjs(range.from), to: dayjs(range.to), startTime: start_time, endTime: end_time });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!rangeForContext || (
|
||||
@@ -289,6 +311,9 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
const [historySeries, setHistorySeries] = useState<ElementHistorySeries[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<LoadingState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [simulationError, setSimulationError] = useState<string | null>(null);
|
||||
const simulationControllerRef = useRef<AbortController | null>(null);
|
||||
const draggableRef = useRef<HTMLDivElement>(null);
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -316,7 +341,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
};
|
||||
}, [type, runId, schemeReload]);
|
||||
|
||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||
const normalizedRange = useMemo(() => {
|
||||
const ordered = ensureValidRange(from, to);
|
||||
const aligned = alignSimulationRange({ from: ordered.from.toDate(), to: ordered.to.toDate() }, stepSeconds);
|
||||
return { from: dayjs(aligned.from), to: dayjs(aligned.to) };
|
||||
}, [from, to, stepSeconds]);
|
||||
|
||||
const featureInfosKey = JSON.stringify(featureInfos);
|
||||
const requestedFeatureInfos = useMemo(
|
||||
@@ -325,6 +354,42 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
);
|
||||
const hasDevices = requestedFeatureInfos.length > 0;
|
||||
const hasData = timeSeries.length > 0;
|
||||
const simulationContext = JSON.stringify([
|
||||
projectId, type, runId, featureInfosKey, start_time, end_time,
|
||||
]);
|
||||
const simulationContextRef = useRef(simulationContext);
|
||||
useEffect(() => {
|
||||
simulationContextRef.current = simulationContext;
|
||||
setSimulationError(null);
|
||||
}, [simulationContext]);
|
||||
|
||||
const handleOnlineSimulation = async () => {
|
||||
if (simulationControllerRef.current || !canRunSimulation || !projectId || !hasDevices || !schemeReady || schemeError) return;
|
||||
const controller = new AbortController();
|
||||
simulationControllerRef.current = controller;
|
||||
setIsSimulating(true);
|
||||
setSimulationError(null);
|
||||
try {
|
||||
const result = await runOnlineSimulation({
|
||||
from: normalizedRange.from.toDate(), to: normalizedRange.to.toDate(),
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted || simulationContextRef.current !== simulationContext) return;
|
||||
// 更新查询范围会触发历史数据重查,读取刚保存的在线结果。
|
||||
setSelectedRange({
|
||||
key: rangeKey, from: dayjs(result.range.from), to: dayjs(result.range.to),
|
||||
startTime: start_time, endTime: end_time,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted && simulationContextRef.current === simulationContext) {
|
||||
setSimulationError(err instanceof Error ? err.message : "在线模拟失败,请刷新确认已生成的数据");
|
||||
}
|
||||
} finally {
|
||||
if (simulationControllerRef.current === controller) {
|
||||
simulationControllerRef.current = null;
|
||||
setIsSimulating(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataset = useMemo(
|
||||
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||
@@ -392,6 +457,8 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
useEffect(
|
||||
() => () => {
|
||||
requestControllerRef.current?.abort();
|
||||
simulationControllerRef.current?.abort();
|
||||
simulationControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -454,6 +521,11 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
);
|
||||
|
||||
const renderEmpty = () => {
|
||||
if (isSimulating) {
|
||||
return <Box sx={{ display: "grid", placeItems: "center", height: "100%" }}>
|
||||
<CircularProgress size={32} />
|
||||
</Box>;
|
||||
}
|
||||
if (!hasDevices) {
|
||||
return (
|
||||
<PanelEmptyState
|
||||
@@ -583,8 +655,10 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
boundaryGap: false,
|
||||
min: normalizedRange.from.valueOf(),
|
||||
max: normalizedRange.to.valueOf(),
|
||||
minInterval: stepSeconds * 1000,
|
||||
axisLabel: {
|
||||
formatter: (value: number) => formatTimestamp(new Date(value).toISOString()),
|
||||
formatter: (value: number) => dayjs(value).tz("Asia/Shanghai").format("MM-DD\nHH:mm"),
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
@@ -801,6 +875,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
<DateTimePicker
|
||||
label="开始时间"
|
||||
value={from}
|
||||
disabled={isSimulating}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value) && value.isValid()) {
|
||||
setFrom(value);
|
||||
@@ -814,6 +889,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
<DateTimePicker
|
||||
label="结束时间"
|
||||
value={to}
|
||||
disabled={isSimulating}
|
||||
onChange={(value) => {
|
||||
if (value && dayjs.isDayjs(value) && value.isValid()) {
|
||||
setTo(value);
|
||||
@@ -826,7 +902,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
/>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
@@ -850,6 +926,23 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
/>
|
||||
</Tabs>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{type !== "none" && (
|
||||
<Tooltip title={canRunSimulation
|
||||
? "按管网水力步长计算当前查询时段的在线模拟数据"
|
||||
: "需要在线模拟运行权限"}>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={isSimulating ? <CircularProgress size={16} color="inherit" /> : <PlayArrow fontSize="small" />}
|
||||
disabled={!canRunSimulation || !projectId || !hasDevices || !schemeReady || Boolean(schemeError) || isSimulating || loadingState === "loading"}
|
||||
onClick={() => { void handleOnlineSimulation(); }}
|
||||
>
|
||||
{isSimulating ? "模拟中…" : "在线模拟"}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="刷新数据">
|
||||
<span>
|
||||
<Button
|
||||
@@ -858,7 +951,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
color="primary"
|
||||
startIcon={<Refresh fontSize="small" />}
|
||||
disabled={
|
||||
!hasDevices || !schemeReady || loadingState === "loading"
|
||||
!hasDevices || !schemeReady || isSimulating || loadingState === "loading"
|
||||
}
|
||||
onClick={() => {
|
||||
if (schemeError) {
|
||||
@@ -883,6 +976,22 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
||||
事件方案时段:{formatTimestamp(eventRange.start)} ~ {formatTimestamp(eventRange.end)}
|
||||
</Typography>
|
||||
)}
|
||||
{isSimulating && (
|
||||
<Typography role="status" variant="caption" sx={{ mt: 1, display: "block" }}>
|
||||
正在计算在线模拟数据,完成后将自动刷新图表。
|
||||
</Typography>
|
||||
)}
|
||||
{!isSimulating && type !== "none" && hasDevices && loadingState === "success" &&
|
||||
!historySeries.some((series) => series.source === "realtime_simulation" && series.points.some((point) => point.value !== null)) && (
|
||||
<Typography variant="caption" sx={{ mt: 1, display: "block" }}>
|
||||
当前时段暂无在线模拟数据。{canRunSimulation ? "可点击“在线模拟”计算。" : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{simulationError && (
|
||||
<Typography role="alert" variant="caption" color="error" sx={{ mt: 1, display: "block" }}>
|
||||
在线模拟失败:{simulationError}
|
||||
</Typography>
|
||||
)}
|
||||
{(schemeError || error) && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { alignSimulationRange, parseHydraulicStepSeconds, runOnlineSimulation } from "./onlineSimulation";
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() }));
|
||||
|
||||
const response = (body: unknown, ok = true) => ({
|
||||
ok, json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
describe("online simulation time alignment", () => {
|
||||
it.each([["0:15", 900], ["00:05:00", 300], ["1:00", 3600], ["0:00:30", 30]])(
|
||||
"reads the configured step %s", (clock, seconds) => {
|
||||
expect(parseHydraulicStepSeconds(clock)).toBe(seconds);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["0:00", "0:60", "0:00:60", "15", "invalid", undefined])(
|
||||
"does not invent a step from invalid config %s", (clock) => {
|
||||
expect(parseHydraulicStepSeconds(clock)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("aligns the screenshot's range to 15-minute clock boundaries", () => {
|
||||
const range = alignSimulationRange({
|
||||
from: new Date("2026-07-30T12:38:42+08:00"),
|
||||
to: new Date("2026-07-30T15:38:42+08:00"),
|
||||
}, 900);
|
||||
expect(range.from.toISOString()).toBe("2026-07-30T04:30:00.000Z");
|
||||
expect(range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
|
||||
});
|
||||
|
||||
it("keeps aligned endpoints and handles ranges crossing midnight", () => {
|
||||
const range = alignSimulationRange({
|
||||
from: new Date("2026-07-30T23:50:00+08:00"),
|
||||
to: new Date("2026-07-31T00:10:00+08:00"),
|
||||
}, 900);
|
||||
expect(range.from.toISOString()).toBe("2026-07-30T15:45:00.000Z");
|
||||
expect(range.to.toISOString()).toBe("2026-07-30T16:15:00.000Z");
|
||||
expect(alignSimulationRange(range, 900)).toEqual(range);
|
||||
});
|
||||
});
|
||||
|
||||
describe("online simulation request", () => {
|
||||
const range = {
|
||||
from: new Date("2026-07-30T12:38:00+08:00"),
|
||||
to: new Date("2026-07-30T15:38:00+08:00"),
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.mocked(apiFetch).mockReset();
|
||||
useProjectStore.getState().setCurrentProject("project-1", "network-1");
|
||||
});
|
||||
|
||||
it("runs the full snapped range including the right endpoint, without network fields", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValueOnce(response({ times: { "HYDRAULIC TIMESTEP": "0:15" } }))
|
||||
.mockResolvedValueOnce(response({ status: "success" }));
|
||||
const result = await runOnlineSimulation(range);
|
||||
expect(result.range.to.toISOString()).toBe("2026-07-30T07:45:00.000Z");
|
||||
expect(JSON.parse(String(jest.mocked(apiFetch).mock.calls[1][1]?.body))).toEqual({
|
||||
start_time: "2026-07-30T04:30:00.000Z", duration: 210,
|
||||
});
|
||||
expect(String(jest.mocked(apiFetch).mock.calls[1][0])).toContain("/api/v1/simulation-runs");
|
||||
});
|
||||
|
||||
it("refuses to calculate with a missing hydraulic step", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValue(response({}));
|
||||
await expect(runOnlineSimulation(range)).rejects.toThrow("水力时间步长无效");
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit a write if the project changed during the config read", async () => {
|
||||
jest.mocked(apiFetch).mockImplementationOnce(async () => {
|
||||
useProjectStore.getState().setCurrentProject("project-2", "network-2");
|
||||
return response({ "HYDRAULIC TIMESTEP": "0:15" });
|
||||
});
|
||||
await expect(runOnlineSimulation(range)).rejects.toThrow("项目已切换");
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry or treat a queued acknowledgment as completion", async () => {
|
||||
jest.mocked(apiFetch).mockResolvedValueOnce(response({ "HYDRAULIC TIMESTEP": "0:15" }))
|
||||
.mockResolvedValueOnce(response({ status: "queued" }));
|
||||
await expect(runOnlineSimulation(range)).rejects.toThrow("未确认完成");
|
||||
expect(apiFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import config from "@/config/config";
|
||||
import { apiFetch } from "@/lib/apiFetch";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export type SimulationRange = { from: Date; to: Date };
|
||||
|
||||
export const parseHydraulicStepSeconds = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "string" || !/^\d+:\d{1,2}(:\d{1,2})?$/.test(value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
const [hours, minutes, seconds = 0] = value.trim().split(":").map(Number);
|
||||
const step = hours * 3600 + minutes * 60 + seconds;
|
||||
return minutes < 60 && seconds < 60 && step > 0 ? step : undefined;
|
||||
};
|
||||
|
||||
/** 与地图时间轴一致,以北京时间零点为基准向外取整,跨日仍保持同一步长。 */
|
||||
export const alignSimulationRange = (
|
||||
range: SimulationRange,
|
||||
stepSeconds: number,
|
||||
): SimulationRange => {
|
||||
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0 ||
|
||||
!Number.isFinite(range.from.getTime()) || !Number.isFinite(range.to.getTime())) {
|
||||
throw new Error("查询时间或水力时间步长无效");
|
||||
}
|
||||
const start = Math.min(range.from.getTime(), range.to.getTime());
|
||||
const end = Math.max(range.from.getTime(), range.to.getTime());
|
||||
const origin = dayjs(start).tz("Asia/Shanghai").startOf("day").valueOf();
|
||||
const step = stepSeconds * 1000;
|
||||
const from = origin + Math.floor((start - origin) / step) * step;
|
||||
const to = Math.max(from + step, origin + Math.ceil((end - origin) / step) * step);
|
||||
return { from: new Date(from), to: new Date(to) };
|
||||
};
|
||||
|
||||
/** 复用同步在线模拟接口,成功响应表示计算和保存已完成;不自动重试写请求。 */
|
||||
export const runOnlineSimulation = async (
|
||||
range: SimulationRange,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ range: SimulationRange; stepSeconds: number }> => {
|
||||
const projectId = useProjectStore.getState().currentProjectId;
|
||||
if (!projectId) throw new Error("请先选择项目,未启动在线模拟");
|
||||
const projectHeaders = { "X-Project-Id": projectId };
|
||||
const timeResponse = await apiFetch(
|
||||
`${config.BACKEND_URL}/api/v1/network-options/time`, {
|
||||
signal, headers: projectHeaders, projectHeaderMode: "omit",
|
||||
},
|
||||
);
|
||||
if (!timeResponse.ok) throw new Error("无法读取管网时间步长,未启动在线模拟");
|
||||
const payload = await timeResponse.json();
|
||||
const stepSeconds = parseHydraulicStepSeconds(
|
||||
(payload?.times ?? payload)?.["HYDRAULIC TIMESTEP"],
|
||||
);
|
||||
if (!stepSeconds) throw new Error("管网水力时间步长无效,未启动在线模拟");
|
||||
if (signal?.aborted || useProjectStore.getState().currentProjectId !== projectId) {
|
||||
throw new Error("项目已切换或面板已关闭,未启动在线模拟");
|
||||
}
|
||||
const alignedRange = alignSimulationRange(range, stepSeconds);
|
||||
const response = await apiFetch(`${config.BACKEND_URL}/api/v1/simulation-runs`, {
|
||||
method: "POST",
|
||||
headers: { ...projectHeaders, "Content-Type": "application/json" },
|
||||
projectHeaderMode: "omit",
|
||||
// 后端循环的结束时间不包含在计算内,多算一个步长以覆盖图表右端点。
|
||||
body: JSON.stringify({
|
||||
start_time: alignedRange.from.toISOString(),
|
||||
duration: Math.ceil((alignedRange.to.getTime() - alignedRange.from.getTime() + stepSeconds * 1000) / 60000),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const result = await response.json().catch(() => null);
|
||||
if (!response.ok || result?.status !== "success") {
|
||||
throw new Error(typeof result?.detail === "string" ? result.detail : "在线模拟未确认完成,请刷新查看已生成的数据");
|
||||
}
|
||||
return { range: alignedRange, stepSeconds };
|
||||
};
|
||||
Reference in New Issue
Block a user