fix(api): align frontend with REST contracts
This commit is contained in:
+1
-1
@@ -135,7 +135,7 @@ const MonitoringPlaceOptimizationPanel: React.FC<
|
||||
const handleOpenScheme = async (schemeId: number) => {
|
||||
setLoadingScheme(true);
|
||||
try {
|
||||
const scheme = await getSensorPlacementScheme(NETWORK_NAME, schemeId);
|
||||
const scheme = await getSensorPlacementScheme(schemeId);
|
||||
setActiveScheme(scheme);
|
||||
setLoadingScheme(false);
|
||||
setPendingOpenSchemeId(scheme.id);
|
||||
|
||||
@@ -90,7 +90,6 @@ const OptimizationParameters: React.FC<OptimizationParametersProps> = ({
|
||||
|
||||
try {
|
||||
const created = await optimizeSensorPlacement({
|
||||
network,
|
||||
scheme_name: schemeName,
|
||||
sensor_type: "pressure",
|
||||
method: method as "sensitivity" | "kmeans",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import SchemeEditor from "./SchemeEditor";
|
||||
import type { SensorPlacementScheme } from "./types";
|
||||
|
||||
const mockOpen = jest.fn();
|
||||
let mockSingleClickHandler: ((event: unknown) => void) | undefined;
|
||||
|
||||
const mockMap = {
|
||||
addLayer: jest.fn(),
|
||||
removeLayer: jest.fn(),
|
||||
on: jest.fn((eventName: string, handler: (event: unknown) => void) => {
|
||||
if (eventName === "singleclick") {
|
||||
mockSingleClickHandler = handler;
|
||||
}
|
||||
}),
|
||||
un: jest.fn(),
|
||||
forEachFeatureAtPixel: jest.fn(() => undefined),
|
||||
getView: jest.fn(() => ({
|
||||
animate: jest.fn(),
|
||||
getZoom: jest.fn(() => 16),
|
||||
getResolution: jest.fn(() => 1),
|
||||
})),
|
||||
};
|
||||
|
||||
jest.mock("@refinedev/core", () => ({
|
||||
useNotification: () => ({ open: mockOpen }),
|
||||
}));
|
||||
|
||||
jest.mock("@components/olmap/core/MapComponent", () => ({
|
||||
useMap: () => mockMap,
|
||||
}));
|
||||
|
||||
jest.mock("@mui/x-data-grid", () => ({
|
||||
DataGrid: () => <div data-testid="scheme-grid" />,
|
||||
GridToolbar: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("@mui/x-data-grid/locales", () => ({
|
||||
zhCN: {
|
||||
components: {
|
||||
MuiDataGrid: {
|
||||
defaultProps: {
|
||||
localeText: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("ol/Feature", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
private values: Record<string, unknown>;
|
||||
|
||||
constructor(values: Record<string, unknown>) {
|
||||
this.values = values;
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.values[key];
|
||||
}
|
||||
|
||||
setId() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("ol/geom/Point", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
constructor(_coordinates: number[]) {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("ol/source/Vector", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
clear() {}
|
||||
addFeature() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("ol/layer/Vector", () => ({
|
||||
__esModule: true,
|
||||
default: class {
|
||||
private source: { clear: () => void; addFeature: () => void };
|
||||
|
||||
constructor(options: {
|
||||
source: { clear: () => void; addFeature: () => void };
|
||||
}) {
|
||||
this.source = options.source;
|
||||
}
|
||||
|
||||
getSource() {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
setVisible() {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("ol/style", () => ({
|
||||
Circle: class {},
|
||||
Fill: class {},
|
||||
Stroke: class {},
|
||||
Style: class {},
|
||||
Text: class {},
|
||||
}));
|
||||
|
||||
jest.mock("ol/proj", () => ({
|
||||
fromLonLat: (coordinates: number[]) => coordinates,
|
||||
toLonLat: (coordinates: number[]) => coordinates,
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/mapQueryService", () => ({
|
||||
handleMapClickSelectFeatures: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("./SchemeDrawingDialog", () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
jest.mock("./schemeApi", () => ({
|
||||
exportSensorPlacementExcel: jest.fn(),
|
||||
overwriteSensorPlacementScheme: jest.fn(),
|
||||
}));
|
||||
|
||||
const scheme: SensorPlacementScheme = {
|
||||
id: 1,
|
||||
scheme_name: "测试方案",
|
||||
sensor_number: 1,
|
||||
min_diameter: 300,
|
||||
username: "operator",
|
||||
create_time: "2026-07-30T08:00:00+08:00",
|
||||
sensor_location: ["J1"],
|
||||
sensor_points: [
|
||||
{
|
||||
node_id: "J1",
|
||||
project_x: 10,
|
||||
project_y: 20,
|
||||
map_x: 13500010,
|
||||
map_y: 3600020,
|
||||
longitude: 121,
|
||||
latitude: 31,
|
||||
elevation: 5,
|
||||
},
|
||||
],
|
||||
can_edit: true,
|
||||
};
|
||||
|
||||
describe("SchemeEditor notifications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSingleClickHandler = undefined;
|
||||
});
|
||||
|
||||
it("uses the same error notification contract as scheme query when replace starts outside an existing sensor", async () => {
|
||||
render(<SchemeEditor scheme={scheme} network="fengyang" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "替换" }));
|
||||
await waitFor(() => expect(mockSingleClickHandler).toBeDefined());
|
||||
|
||||
act(() => {
|
||||
mockSingleClickHandler?.({
|
||||
pixel: [0, 0],
|
||||
coordinate: [13500000, 3600000],
|
||||
stopPropagation: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
message: "请先点击要替换的监测点",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
EditLocationAlt as ReplaceIcon,
|
||||
LocationOn as LocateIcon,
|
||||
Map as MapIcon,
|
||||
Redo as ResetIcon,
|
||||
Redo as RedoIcon,
|
||||
RestartAlt as ResetIcon,
|
||||
Save as SaveIcon,
|
||||
Undo as UndoIcon,
|
||||
} from "@mui/icons-material";
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
createSchemeEditorState,
|
||||
deleteSensorPoint,
|
||||
isSchemeDirty,
|
||||
redoSchemeEdit,
|
||||
replaceSensorPoint,
|
||||
resetSchemeEdit,
|
||||
summarizeChanges,
|
||||
@@ -230,8 +232,8 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
x: number;
|
||||
y: number;
|
||||
elevation: number;
|
||||
}>(`${config.BACKEND_URL}/api/v1/getjunctionproperties/`, {
|
||||
params: { network, junction: nodeId },
|
||||
}>(`${config.BACKEND_URL}/api/v1/junctions/properties`, {
|
||||
params: { junction: nodeId },
|
||||
});
|
||||
if (!response.data?.id) return null;
|
||||
const [longitude, latitude] = toLonLat([mapX, mapY]);
|
||||
@@ -249,7 +251,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[map, network],
|
||||
[map],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -275,7 +277,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
|
||||
if (mode === "delete") {
|
||||
if (!markerNodeId) {
|
||||
open?.({ type: "progress", message: "请点击要删除的监测点" });
|
||||
open?.({ type: "error", message: "请点击要删除的监测点" });
|
||||
return;
|
||||
}
|
||||
setEditor((current) => deleteSensorPoint(current, markerNodeId));
|
||||
@@ -285,7 +287,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
|
||||
if (mode === "replace" && !replaceSourceId) {
|
||||
if (!markerNodeId) {
|
||||
open?.({ type: "progress", message: "请先点击要替换的监测点" });
|
||||
open?.({ type: "error", message: "请先点击要替换的监测点" });
|
||||
return;
|
||||
}
|
||||
setReplaceSourceId(markerNodeId);
|
||||
@@ -295,7 +297,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
|
||||
const candidate = await resolveJunction(event);
|
||||
if (!candidate) {
|
||||
open?.({ type: "progress", message: "请选择有效的管网节点" });
|
||||
open?.({ type: "error", message: "请选择有效的管网节点" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,7 +306,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
(point) => point.node_id === candidate.node_id,
|
||||
);
|
||||
if (exists) {
|
||||
open?.({ type: "progress", message: "该节点已在当前方案中" });
|
||||
open?.({ type: "error", message: "该节点已在当前方案中" });
|
||||
return;
|
||||
}
|
||||
setEditor((current) => addSensorPoint(current, candidate));
|
||||
@@ -316,7 +318,7 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
point.node_id !== replaceSourceId,
|
||||
);
|
||||
if (duplicate) {
|
||||
open?.({ type: "progress", message: "目标节点已在当前方案中" });
|
||||
open?.({ type: "error", message: "目标节点已在当前方案中" });
|
||||
return;
|
||||
}
|
||||
setEditor((current) =>
|
||||
@@ -379,7 +381,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await overwriteSensorPlacementScheme(
|
||||
network,
|
||||
scheme.id,
|
||||
editor.baseline.map((point) => point.node_id),
|
||||
editor.points.map((point) => point.node_id),
|
||||
@@ -410,7 +411,6 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportSensorPlacementExcel(
|
||||
network,
|
||||
scheme.id,
|
||||
editor.points.map((point) => point.node_id),
|
||||
editor.statuses,
|
||||
@@ -693,6 +693,19 @@ const SchemeEditor: React.FC<SchemeEditorProps> = ({
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做下一步">
|
||||
<span>
|
||||
<IconButton
|
||||
aria-label="重做下一步"
|
||||
size="small"
|
||||
disabled={!editor.future.length}
|
||||
onClick={() => setEditor((current) => redoSchemeEdit(current))}
|
||||
sx={{ width: 40, height: 40 }}
|
||||
>
|
||||
<RedoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置为服务器版本">
|
||||
<span>
|
||||
<IconButton
|
||||
|
||||
@@ -177,7 +177,7 @@ const SchemeQuery: React.FC<SchemeQueryProps> = ({
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes?network=${network}`,
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes`,
|
||||
);
|
||||
|
||||
let filteredResults = response.data;
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
export interface OptimizeSchemeInput {
|
||||
network: string;
|
||||
scheme_name: string;
|
||||
sensor_type: "pressure";
|
||||
method: "sensitivity" | "kmeans";
|
||||
@@ -18,25 +17,22 @@ export const optimizeSensorPlacement = async (
|
||||
input: OptimizeSchemeInput,
|
||||
): Promise<SensorPlacementScheme> => {
|
||||
const response = await api.post<SensorPlacementScheme>(
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/optimize`,
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-optimization-runs`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getSensorPlacementScheme = async (
|
||||
network: string,
|
||||
schemeId: number,
|
||||
): Promise<SensorPlacementScheme> => {
|
||||
const response = await api.get<SensorPlacementScheme>(
|
||||
`${config.BACKEND_URL}/api/v1/sensor-placement-schemes/${schemeId}`,
|
||||
{ params: { network } },
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const overwriteSensorPlacementScheme = async (
|
||||
network: string,
|
||||
schemeId: number,
|
||||
expectedSensorLocation: string[],
|
||||
sensorLocation: string[],
|
||||
@@ -47,13 +43,11 @@ export const overwriteSensorPlacementScheme = async (
|
||||
expected_sensor_location: expectedSensorLocation,
|
||||
sensor_location: sensorLocation,
|
||||
},
|
||||
{ params: { network } },
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const exportSensorPlacementExcel = async (
|
||||
network: string,
|
||||
schemeId: number,
|
||||
sensorLocation: string[],
|
||||
adjustmentStatus: Record<string, AdjustmentStatus>,
|
||||
@@ -65,7 +59,6 @@ export const exportSensorPlacementExcel = async (
|
||||
adjustment_status: adjustmentStatus,
|
||||
},
|
||||
{
|
||||
params: { network },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createSchemeEditorState,
|
||||
deleteSensorPoint,
|
||||
isSchemeDirty,
|
||||
redoSchemeEdit,
|
||||
replaceSensorPoint,
|
||||
resetSchemeEdit,
|
||||
summarizeChanges,
|
||||
@@ -44,6 +45,24 @@ describe("scheme editor", () => {
|
||||
expect(undoSchemeEdit(added).points).toEqual(initial.points);
|
||||
});
|
||||
|
||||
it("can redo an undone edit and clears redo history after a new edit", () => {
|
||||
const initial = createSchemeEditorState(scheme);
|
||||
const added = addSensorPoint(initial, point("J3"));
|
||||
const undone = undoSchemeEdit(added);
|
||||
const redone = redoSchemeEdit(undone);
|
||||
|
||||
expect(redone.points.map((item) => item.node_id)).toEqual([
|
||||
"J1",
|
||||
"J2",
|
||||
"J3",
|
||||
]);
|
||||
expect(redone.history).toHaveLength(1);
|
||||
expect(redone.future).toEqual([]);
|
||||
|
||||
const editedAfterUndo = replaceSensorPoint(undone, "J1", point("J4"));
|
||||
expect(editedAfterUndo.future).toEqual([]);
|
||||
});
|
||||
|
||||
it("replaces a node without changing row order", () => {
|
||||
const initial = createSchemeEditorState(scheme);
|
||||
const replaced = replaceSensorPoint(initial, "J1", point("J3"));
|
||||
@@ -80,5 +99,6 @@ describe("scheme editor", () => {
|
||||
expect(reset.points.map((item) => item.node_id)).toEqual(["J1", "J2"]);
|
||||
expect(isSchemeDirty(reset)).toBe(false);
|
||||
expect(reset.history).toEqual([]);
|
||||
expect(reset.future).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SchemeEditorSnapshot {
|
||||
export interface SchemeEditorState extends SchemeEditorSnapshot {
|
||||
baseline: SensorPoint[];
|
||||
history: SchemeEditorSnapshot[];
|
||||
future: SchemeEditorSnapshot[];
|
||||
}
|
||||
|
||||
const clonePoints = (points: SensorPoint[]) => points.map((point) => ({ ...point }));
|
||||
@@ -30,6 +31,7 @@ export const createSchemeEditorState = (
|
||||
points: clonePoints(scheme.sensor_points),
|
||||
statuses: currentStatuses(scheme.sensor_points),
|
||||
history: [],
|
||||
future: [],
|
||||
});
|
||||
|
||||
const snapshot = (state: SchemeEditorState): SchemeEditorSnapshot => ({
|
||||
@@ -46,6 +48,7 @@ const withHistory = (
|
||||
points,
|
||||
statuses,
|
||||
history: [...state.history, snapshot(state)],
|
||||
future: [],
|
||||
});
|
||||
|
||||
export const addSensorPoint = (
|
||||
@@ -114,6 +117,19 @@ export const undoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
||||
points: clonePoints(previous.points),
|
||||
statuses: { ...previous.statuses },
|
||||
history: state.history.slice(0, -1),
|
||||
future: [...state.future, snapshot(state)],
|
||||
};
|
||||
};
|
||||
|
||||
export const redoSchemeEdit = (state: SchemeEditorState): SchemeEditorState => {
|
||||
const next = state.future[state.future.length - 1];
|
||||
if (!next) return state;
|
||||
return {
|
||||
...state,
|
||||
points: clonePoints(next.points),
|
||||
statuses: { ...next.statuses },
|
||||
history: [...state.history, snapshot(state)],
|
||||
future: state.future.slice(0, -1),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -122,6 +138,7 @@ export const resetSchemeEdit = (state: SchemeEditorState): SchemeEditorState =>
|
||||
points: clonePoints(state.baseline),
|
||||
statuses: currentStatuses(state.baseline),
|
||||
history: [],
|
||||
future: [],
|
||||
});
|
||||
|
||||
export const isSchemeDirty = (state: SchemeEditorState) => {
|
||||
|
||||
Reference in New Issue
Block a user