Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a90be0d3 | ||
|
|
1b275206a9 |
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { AgentActivityTimeline } from "./AgentActivityTimeline";
|
||||||
|
|
||||||
|
describe("AgentActivityTimeline", () => {
|
||||||
|
it("shows only the first lines of a long tool command until expanded", () => {
|
||||||
|
const target = [
|
||||||
|
"cd /app/data/conversation-workspaces/conversation-example",
|
||||||
|
"python3 - <<'EOF'",
|
||||||
|
"import json, glob",
|
||||||
|
"print('fourth line')",
|
||||||
|
"EOF",
|
||||||
|
].join("\n");
|
||||||
|
render(
|
||||||
|
<AgentActivityTimeline
|
||||||
|
activities={[
|
||||||
|
{
|
||||||
|
id: "analysis",
|
||||||
|
title: "处理分析数据",
|
||||||
|
reason: "正在核查结果。",
|
||||||
|
status: "completed",
|
||||||
|
startedAt: 1000,
|
||||||
|
endedAt: 2000,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "long-command",
|
||||||
|
tool: "bash",
|
||||||
|
title: "运行本地分析",
|
||||||
|
status: "completed",
|
||||||
|
target,
|
||||||
|
startedAt: 1100,
|
||||||
|
endedAt: 1900,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "short-command",
|
||||||
|
tool: "tjwater_cli",
|
||||||
|
title: "查询后端数据",
|
||||||
|
status: "completed",
|
||||||
|
target: "help data",
|
||||||
|
startedAt: 1900,
|
||||||
|
endedAt: 1950,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开分析过程" }));
|
||||||
|
expect(screen.getByText(/import json, glob/u)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("help data")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "展开完整内容" })).toHaveAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开完整内容" }));
|
||||||
|
expect(screen.getByText(/fourth line/u)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "收起内容" })).toHaveAttribute(
|
||||||
|
"aria-expanded",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "收起内容" }));
|
||||||
|
expect(screen.queryByText(/fourth line/u)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Collapse,
|
Collapse,
|
||||||
IconButton,
|
IconButton,
|
||||||
LinearProgress,
|
LinearProgress,
|
||||||
@@ -89,6 +90,11 @@ const ActionStatusIcon = ({ status }: { status: AgentActivityAction["status"] })
|
|||||||
|
|
||||||
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }) => {
|
||||||
const elapsed = getElapsedMs(action, now);
|
const elapsed = getElapsedMs(action, now);
|
||||||
|
const [targetExpanded, setTargetExpanded] = useState(false);
|
||||||
|
const targetId = React.useId();
|
||||||
|
const target = action.target ?? "";
|
||||||
|
const targetPreview = target.split(/\r?\n/u).slice(0, 3).join("\n").slice(0, 240);
|
||||||
|
const hasLongTarget = targetPreview.length < target.length;
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
@@ -111,19 +117,45 @@ const ActionRow = ({ action, now }: { action: AgentActivityAction; now: number }
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
{action.target ? (
|
{action.target ? (
|
||||||
<Typography
|
<Box sx={{ mt: 0.2 }}>
|
||||||
variant="caption"
|
<Typography
|
||||||
color="text.secondary"
|
id={targetId}
|
||||||
sx={{
|
variant="caption"
|
||||||
display: "block",
|
color="text.secondary"
|
||||||
mt: 0.2,
|
sx={{
|
||||||
fontFamily: "monospace",
|
display: !targetExpanded && hasLongTarget ? "-webkit-box" : "block",
|
||||||
wordBreak: "break-word",
|
fontFamily: "monospace",
|
||||||
whiteSpace: "pre-wrap",
|
overflow: !targetExpanded && hasLongTarget ? "hidden" : "visible",
|
||||||
}}
|
overflowWrap: "anywhere",
|
||||||
>
|
whiteSpace: "pre-wrap",
|
||||||
{action.target}
|
...(!targetExpanded && hasLongTarget
|
||||||
</Typography>
|
? { WebkitBoxOrient: "vertical", WebkitLineClamp: 3 }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{targetExpanded ? target : targetPreview}
|
||||||
|
</Typography>
|
||||||
|
{hasLongTarget ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
aria-controls={targetId}
|
||||||
|
aria-expanded={targetExpanded}
|
||||||
|
onClick={() => setTargetExpanded((current) => !current)}
|
||||||
|
sx={{
|
||||||
|
mt: 0.2,
|
||||||
|
p: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
color: activityAccent,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 650,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
textTransform: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{targetExpanded ? "收起内容" : "展开完整内容"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{action.error ? (
|
{action.error ? (
|
||||||
<Typography
|
<Typography
|
||||||
|
|||||||
@@ -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(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
rerender(<SCADADataPanel deviceIds={["S-1"]} />);
|
||||||
|
await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
rerender(<SCADADataPanel deviceIds={["S-1", "S-2"]} />);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -307,9 +307,15 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
|
|
||||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
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 hasData = timeSeries.length > 0;
|
||||||
const deviceIdsKey = useMemo(() => deviceIds.join(","), [deviceIds]);
|
|
||||||
|
|
||||||
const dataset = useMemo(
|
const dataset = useMemo(
|
||||||
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
() => buildDataset(timeSeries, deviceIds, fractionDigits, showCleaning),
|
||||||
@@ -348,7 +354,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||||
const result = await customFetcher(deviceIds, {
|
const result = await customFetcher(requestedDeviceIds, {
|
||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
});
|
});
|
||||||
@@ -360,7 +366,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deviceIds, customFetcher, hasDevices, normalizedRange],
|
[requestedDeviceIds, customFetcher, hasDevices, normalizedRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 处理数据清洗
|
// 处理数据清洗
|
||||||
@@ -447,7 +453,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
}
|
}
|
||||||
}, [deviceIdsKey, handleFetch, hasDevices]);
|
}, [handleFetch, hasDevices]);
|
||||||
|
|
||||||
// 当设备数量变化时,调整数据源选择
|
// 当设备数量变化时,调整数据源选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, waitFor } from "@testing-library/react";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { fetchHistoryData } from "./HistoryDataPanel";
|
import HistoryDataPanel, { fetchHistoryData } from "./HistoryDataPanel";
|
||||||
|
|
||||||
jest.mock("@/lib/apiFetch", () => ({
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
apiFetch: jest.fn(),
|
apiFetch: jest.fn(),
|
||||||
@@ -9,6 +11,12 @@ jest.mock("@components/olmap/core/MapComponent", () => ({
|
|||||||
useData: () => null,
|
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) =>
|
const jsonResponse = (payload: unknown, status = 200) =>
|
||||||
({
|
({
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
@@ -94,3 +102,39 @@ describe("fetchHistoryData", () => {
|
|||||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -258,12 +258,13 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
|
|
||||||
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
const normalizedRange = useMemo(() => ensureValidRange(from, to), [from, to]);
|
||||||
|
|
||||||
const hasDevices = deviceIds.length > 0;
|
const featureInfosKey = JSON.stringify(featureInfos);
|
||||||
const hasData = timeSeries.length > 0;
|
const requestedFeatureInfos = useMemo(
|
||||||
const featureInfosKey = useMemo(
|
() => JSON.parse(featureInfosKey) as [string, string][],
|
||||||
() => JSON.stringify(featureInfos),
|
[featureInfosKey],
|
||||||
[featureInfos]
|
|
||||||
);
|
);
|
||||||
|
const hasDevices = requestedFeatureInfos.length > 0;
|
||||||
|
const hasData = timeSeries.length > 0;
|
||||||
|
|
||||||
const dataset = useMemo(
|
const dataset = useMemo(
|
||||||
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
() => buildDataset(timeSeries, historySeries, fractionDigits),
|
||||||
@@ -289,7 +290,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
try {
|
try {
|
||||||
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
const { from: rangeFrom, to: rangeTo } = normalizedRange;
|
||||||
const result = await fetchHistoryData(
|
const result = await fetchHistoryData(
|
||||||
featureInfos,
|
requestedFeatureInfos,
|
||||||
{
|
{
|
||||||
from: rangeFrom.toDate(),
|
from: rangeFrom.toDate(),
|
||||||
to: rangeTo.toDate(),
|
to: rangeTo.toDate(),
|
||||||
@@ -313,7 +314,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setLoadingState("error");
|
setLoadingState("error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[featureInfos, hasDevices, normalizedRange, runId, type],
|
[requestedFeatureInfos, hasDevices, normalizedRange, runId, type],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -331,7 +332,7 @@ const SCADADataPanel: React.FC<SCADADataPanelProps> = ({
|
|||||||
setTimeSeries([]);
|
setTimeSeries([]);
|
||||||
setHistorySeries([]);
|
setHistorySeries([]);
|
||||||
}
|
}
|
||||||
}, [featureInfosKey, handleFetch, hasDevices]);
|
}, [handleFetch, hasDevices]);
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(() => {
|
const columns: GridColDef[] = useMemo(() => {
|
||||||
const base: GridColDef[] = [
|
const base: GridColDef[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user