From c4a90be0d3a6e61c307f8eaabea47b197373cdca Mon Sep 17 00:00:00 2001 From: Huarch Date: Fri, 25 Sep 2026 17:22:42 +0800 Subject: [PATCH] fix(scada): avoid history refetch on map zoom --- e2e/scada-history-zoom.spec.ts | 63 +++++++++++++++++++ .../olmap/SCADA/SCADADataPanel.test.tsx | 50 +++++++++++++++ src/components/olmap/SCADA/SCADADataPanel.tsx | 16 +++-- .../core/Controls/HistoryDataPanel.test.ts | 46 +++++++++++++- .../olmap/core/Controls/HistoryDataPanel.tsx | 17 ++--- 5 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 e2e/scada-history-zoom.spec.ts create mode 100644 src/components/olmap/SCADA/SCADADataPanel.test.tsx diff --git a/e2e/scada-history-zoom.spec.ts b/e2e/scada-history-zoom.spec.ts new file mode 100644 index 0000000..b6a7fee --- /dev/null +++ b/e2e/scada-history-zoom.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; + +import { mockBackend } from "./support/mockBackend"; + +test("地图缩放不会重新请求已打开的 SCADA 历史数据", async ({ page }) => { + await mockBackend(page); + + await page.route("**/ows?**", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "S-1", + properties: { + id: "S-1", + device_type: "pressure", + reliability: 0.95, + transmission_frequency: "00:15:00", + }, + geometry: { type: "Point", coordinates: [13530000, 3620000] }, + }, + ], + }), + }), + ); + await page.route("**/api/v1/scada-devices", (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify([ + { device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null }, + ]), + }), + ); + + let historyRequests = 0; + await page.route("**/api/v1/timeseries/views/element-history/query", (route) => { + historyRequests += 1; + return route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ series: [] }), + }); + }); + + await page.goto("/network-simulation"); + await page.getByText("S-1", { exact: true }).click(); + await expect(page.getByText("SCADA 历史数据")).toBeVisible(); + await expect.poll(() => historyRequests).toBe(1); + + const zoomLabel = page.getByText(/缩放: \d+\.\d/); + const initialZoom = await zoomLabel.textContent(); + // The history drawer covers the map controls. Invoke their real handlers + // without targeting a covered screen coordinate. + await page.getByRole("button", { name: "放大" }).evaluate((button: HTMLButtonElement) => button.click()); + await expect(zoomLabel).not.toHaveText(initialZoom!); + expect(historyRequests).toBe(1); + + await page.getByRole("button", { name: "缩小" }).evaluate((button: HTMLButtonElement) => button.click()); + await expect(zoomLabel).toHaveText(initialZoom!); + expect(historyRequests).toBe(1); +}); diff --git a/src/components/olmap/SCADA/SCADADataPanel.test.tsx b/src/components/olmap/SCADA/SCADADataPanel.test.tsx new file mode 100644 index 0000000..2515a77 --- /dev/null +++ b/src/components/olmap/SCADA/SCADADataPanel.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { apiFetch } from "@/lib/apiFetch"; +import SCADADataPanel from "./SCADADataPanel"; + +jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn() })); +jest.mock("@refinedev/core", () => ({ + useGetIdentity: () => ({ data: { id: "test-user" } }), + useNotification: () => ({ open: jest.fn() }), +})); +jest.mock("@mui/x-date-pickers", () => ({ + DateTimePicker: () => null, + LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock("echarts-for-react", () => () => null); + +const response = (payload: unknown) => + ({ ok: true, json: async () => payload }) as Response; + +describe("SCADADataPanel requests", () => { + beforeEach(() => { + jest.mocked(apiFetch).mockImplementation(async (url) => + response( + String(url).includes("/scada-devices") + ? [ + { device_id: "S-1", device_type: "pressure", node_id: "J-1", link_id: null }, + { device_id: "S-2", device_type: "pressure", node_id: "J-2", link_id: null }, + ] + : { series: [] }, + ), + ); + }); + + afterEach(() => jest.clearAllMocks()); + + it("does not request history again when the same device IDs arrive in a new array", async () => { + const { rerender } = render(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + + rerender(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + + rerender(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(4)); + expect(jest.mocked(apiFetch).mock.calls[3][1]?.body).toContain('"device_ids":["S-2"]'); + + fireEvent.click(screen.getByRole("button", { name: "刷新" })); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(6)); + }); +}); diff --git a/src/components/olmap/SCADA/SCADADataPanel.tsx b/src/components/olmap/SCADA/SCADADataPanel.tsx index 4f8fb02..920e4c4 100644 --- a/src/components/olmap/SCADA/SCADADataPanel.tsx +++ b/src/components/olmap/SCADA/SCADADataPanel.tsx @@ -307,9 +307,15 @@ const SCADADataPanel: React.FC = ({ const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); - const hasDevices = deviceIds.length > 0; + // Parent map updates can pass a new array with identical IDs. Keep the + // request dependency tied to the IDs themselves, not the array identity. + const deviceIdsKey = JSON.stringify(deviceIds); + const requestedDeviceIds = useMemo( + () => JSON.parse(deviceIdsKey) as string[], + [deviceIdsKey], + ); + const hasDevices = requestedDeviceIds.length > 0; const hasData = timeSeries.length > 0; - const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]); const dataset = useMemo( () => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning), @@ -348,7 +354,7 @@ const SCADADataPanel: React.FC = ({ setError(null); try { const { from: rangeFrom, to: rangeTo } = normalizedRange; - const result = await customFetcher(deviceIds, { + const result = await customFetcher(requestedDeviceIds, { from: rangeFrom.toDate(), to: rangeTo.toDate(), }); @@ -360,7 +366,7 @@ const SCADADataPanel: React.FC = ({ setLoadingState("error"); } }, - [deviceIds, customFetcher, hasDevices, normalizedRange], + [requestedDeviceIds, customFetcher, hasDevices, normalizedRange], ); // 处理数据清洗 @@ -447,7 +453,7 @@ const SCADADataPanel: React.FC = ({ setTimeSeries([]); setHistorySeries([]); } - }, [deviceIdsKey, handleFetch, hasDevices]); + }, [handleFetch, hasDevices]); // 当设备数量变化时,调整数据源选择 useEffect(() => { diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts index f96b81b..e5e3f79 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.test.ts +++ b/src/components/olmap/core/Controls/HistoryDataPanel.test.ts @@ -1,5 +1,7 @@ +import React from "react"; +import { render, waitFor } from "@testing-library/react"; import { apiFetch } from "@/lib/apiFetch"; -import { fetchHistoryData } from "./HistoryDataPanel"; +import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel"; jest.mock("@/lib/apiFetch", () => ({ apiFetch: jest.fn(), @@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({ useData: () => null, })); +jest.mock("@mui/x-date-pickers", () => ({ + DateTimePicker: () => null, + LocalizationProvider: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock("echarts-for-react", () => () => null); + const jsonResponse = (payload: unknown, status = 200) => ({ ok: status >= 200 && status < 300, @@ -94,3 +102,39 @@ describe("fetchHistoryData", () => { expect(apiFetch).toHaveBeenCalledTimes(1); }); }); + +describe("HistoryDataPanel requests", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(apiFetch).mockResolvedValue(jsonResponse({ series: [] })); + }); + + afterEach(() => jest.clearAllMocks()); + + it("does not reload unchanged elements when its parent rerenders", async () => { + const props = { type: "none" as const, onClose: jest.fn() }; + const { rerender } = render( + React.createElement(HistoryDataPanel, { + ...props, + featureInfos: [["J-1", "junction"]], + }), + ); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + + rerender( + React.createElement(HistoryDataPanel, { + ...props, + featureInfos: [["J-1", "junction"]], + }), + ); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + + rerender( + React.createElement(HistoryDataPanel, { + ...props, + featureInfos: [["J-2", "junction"]], + }), + ); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/components/olmap/core/Controls/HistoryDataPanel.tsx b/src/components/olmap/core/Controls/HistoryDataPanel.tsx index 83c2b8e..26bacdf 100644 --- a/src/components/olmap/core/Controls/HistoryDataPanel.tsx +++ b/src/components/olmap/core/Controls/HistoryDataPanel.tsx @@ -258,12 +258,13 @@ const SCADADataPanel: React.FC = ({ const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]); - const hasDevices = deviceIds.length > 0; - const hasData = timeSeries.length > 0; - const featureInfosKey = useMemo( - () => JSON.stringify(featureInfos), - [featureInfos] + const featureInfosKey = JSON.stringify(featureInfos); + const requestedFeatureInfos = useMemo( + () => JSON.parse(featureInfosKey) as [string, string][], + [featureInfosKey], ); + const hasDevices = requestedFeatureInfos.length > 0; + const hasData = timeSeries.length > 0; const dataset = useMemo( () => buildDataset(timeSeries, historySeries, fractionDigits), @@ -289,7 +290,7 @@ const SCADADataPanel: React.FC = ({ try { const { from: rangeFrom, to: rangeTo } = normalizedRange; const result = await fetchHistoryData( - featureInfos, + requestedFeatureInfos, { from: rangeFrom.toDate(), to: rangeTo.toDate(), @@ -313,7 +314,7 @@ const SCADADataPanel: React.FC = ({ setLoadingState("error"); } }, - [featureInfos, hasDevices, normalizedRange, runId, type], + [requestedFeatureInfos, hasDevices, normalizedRange, runId, type], ); useEffect( @@ -331,7 +332,7 @@ const SCADADataPanel: React.FC = ({ setTimeSeries([]); setHistorySeries([]); } - }, [featureInfosKey, handleFetch, hasDevices]); + }, [handleFetch, hasDevices]); const columns: GridColDef[] = useMemo(() => { const base: GridColDef[] = [