feat(3d): integrate ZJB scene with project context
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
buildSceneFrame,
|
||||
fetchPressureDevices,
|
||||
fetchSceneFrame,
|
||||
supportsThreeDimensionalScene,
|
||||
type SceneModelIndex,
|
||||
} from "./sceneData";
|
||||
|
||||
const mockApiFetch = jest.fn();
|
||||
|
||||
jest.mock("@/lib/apiFetch", () => ({
|
||||
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
|
||||
}));
|
||||
|
||||
const model: SceneModelIndex = {
|
||||
modelId: "zjb-water-network-v23",
|
||||
nodeIds: new Set(["J-1", "J-2"]),
|
||||
linkIds: new Set(["P-1", "P-2"]),
|
||||
};
|
||||
|
||||
describe("buildSceneFrame", () => {
|
||||
beforeEach(() => {
|
||||
mockApiFetch.mockReset();
|
||||
});
|
||||
|
||||
it("enables the scene only for the normalized ZJB project code", () => {
|
||||
expect(supportsThreeDimensionalScene(" ZJB ")).toBe(true);
|
||||
expect(supportsThreeDimensionalScene("tjwater_v2")).toBe(false);
|
||||
expect(supportsThreeDimensionalScene(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("combines one complete simulation frame and overrides pressure with cleaned SCADA", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-2",
|
||||
pressure: 29.7,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.82,
|
||||
flow: -18.4,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
scadaRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:01.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: 30.9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(frame.resultTime).toBe("2026-09-11T03:00:00.000Z");
|
||||
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||
pressure: 30.9,
|
||||
source: "scada",
|
||||
deviceId: "S-1",
|
||||
simulationPressure: 31.2,
|
||||
});
|
||||
expect(frame.payload?.links["P-1"]).toEqual({
|
||||
velocity: 0.82,
|
||||
flow: -18.4,
|
||||
direction: -1,
|
||||
status: "open",
|
||||
});
|
||||
expect(frame.stats).toMatchObject({
|
||||
simulationNodes: 2,
|
||||
simulationLinks: 1,
|
||||
scadaOverrides: 1,
|
||||
missingNodes: 0,
|
||||
missingLinks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses monitored SCADA when cleaned data is absent", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.2,
|
||||
flow: 2,
|
||||
status: 0,
|
||||
},
|
||||
],
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
scadaRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
device_id: "S-1",
|
||||
monitored_value: 30.8,
|
||||
cleaned_value: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes["J-1"]?.pressure).toBe(30.8);
|
||||
expect(frame.payload?.links["P-1"]?.status).toBe("closed");
|
||||
});
|
||||
|
||||
it("keeps the selected time and returns no payload when a complete frame is absent", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T02:45:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [],
|
||||
pressureDevices: [],
|
||||
scadaRows: [],
|
||||
});
|
||||
|
||||
expect(frame.selectedTime).toBe("2026-09-11T03:00:00.000Z");
|
||||
expect(frame.resultTime).toBeNull();
|
||||
expect(frame.payload).toBeNull();
|
||||
expect(frame.stats.missingNodes).toBe(2);
|
||||
expect(frame.stats.missingLinks).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores IDs outside the scene model instead of rejecting the frame", () => {
|
||||
const frame = buildSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
nodeRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-UNKNOWN",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
linkRows: [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-UNKNOWN",
|
||||
velocity: 0.2,
|
||||
flow: 2,
|
||||
status: 2,
|
||||
},
|
||||
],
|
||||
pressureDevices: [],
|
||||
scadaRows: [],
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes).toEqual({});
|
||||
expect(frame.payload?.links).toEqual({});
|
||||
expect(frame.stats.ignoredElements).toBe(2);
|
||||
});
|
||||
|
||||
it("reads pressure mappings from the paginated SCADA device response", async () => {
|
||||
mockApiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: " J-1 " },
|
||||
{ device_id: "S-2", device_type: "flow", node_id: "J-2" },
|
||||
{ device_id: "S-3", device_type: "pressure", node_id: null },
|
||||
],
|
||||
total: 3,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const devices = await fetchPressureDevices(new AbortController().signal);
|
||||
|
||||
expect(devices).toEqual([
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
]);
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/scada-devices?limit=1000&offset=0"),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps simulation results when SCADA readings are temporarily unavailable", async () => {
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
node_id: "J-1",
|
||||
pressure: 31.2,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{
|
||||
time: "2026-09-11T03:00:00.000Z",
|
||||
link_id: "P-1",
|
||||
velocity: 0.8,
|
||||
flow: 12,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => "SCADA service unavailable",
|
||||
});
|
||||
|
||||
const frame = await fetchSceneFrame({
|
||||
queryTime: new Date("2026-09-11T03:00:00.000Z"),
|
||||
model,
|
||||
pressureDevices: [
|
||||
{ device_id: "S-1", device_type: "pressure", node_id: "J-1" },
|
||||
],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(frame.payload?.nodes["J-1"]).toEqual({
|
||||
pressure: 31.2,
|
||||
source: "simulation",
|
||||
});
|
||||
expect(frame.stats.scadaOverrides).toBe(0);
|
||||
expect(frame.warnings).toEqual([
|
||||
"SCADA 数据暂不可用,当前仅显示模拟结果。",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user