forked from jappel/leistungsbilanz-ts
A board with its complete circuit list can be downloaded as a leistungsbilanz.distribution-board file and imported into any project as a new board. The import reuses distribution-board.insert-subtree, so it is one revision with restart-safe Undo/Redo. All ids are new; project-bound references (project devices with override metadata, room ids, floor, logical feed, a disabled supply type and the Revit quantity share) are dropped, circuit voltages follow the target project, and the response lists these adjustments. The project page offers export per board and import in the board card header. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
/**
|
|
* Distribution-board export/import: pure import preparation plus the HTTP
|
|
* round trip from one project into another against createApp() with an
|
|
* in-memory database and synthetic accounts.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import type { AddressInfo } from "node:net";
|
|
import { after, before, describe, it } from "node:test";
|
|
import type { DistributionBoardSubtreeSnapshot } from "../src/domain/models/distribution-board-subtree-snapshot.model.js";
|
|
import {
|
|
createDistributionBoardTransferEnvelope,
|
|
parseDistributionBoardTransferEnvelope,
|
|
prepareDistributionBoardImport,
|
|
} from "../src/domain/models/distribution-board-transfer.model.js";
|
|
import config, { WRITE_HEADER, signIn } from "./security/security.config.ts";
|
|
|
|
type Headers = Record<string, string>;
|
|
|
|
function sourceBoard(): DistributionBoardSubtreeSnapshot {
|
|
return {
|
|
distributionBoard: {
|
|
id: "board-1",
|
|
projectId: "project-source",
|
|
name: "UV-EG",
|
|
floorId: "floor-1",
|
|
supplyType: "SV",
|
|
simultaneityFactor: 0.8,
|
|
feedingDistributionBoardId: "board-main",
|
|
feedingCircuitId: null,
|
|
boardNumber: "UV-1",
|
|
location: "Technik",
|
|
fedFrom: null,
|
|
remark: null,
|
|
},
|
|
circuitList: {
|
|
id: "board-1",
|
|
projectId: "project-source",
|
|
distributionBoardId: "board-1",
|
|
name: "UV-EG Stromkreisliste",
|
|
},
|
|
sections: [
|
|
{
|
|
id: "section-1",
|
|
circuitListId: "board-1",
|
|
key: "single_phase",
|
|
displayName: "Einphasig",
|
|
prefix: "-2F1.",
|
|
sortOrder: 20,
|
|
category: "single_phase",
|
|
groupNumber: 1,
|
|
},
|
|
],
|
|
components: [],
|
|
circuits: [
|
|
{
|
|
id: "circuit-1",
|
|
circuitListId: "board-1",
|
|
sectionId: "section-1",
|
|
equipmentIdentifier: "-2F1.1",
|
|
displayName: "Steckdose",
|
|
sortOrder: 10,
|
|
cableType: "NYM-J",
|
|
cableCrossSection: "3x2,5",
|
|
cableLength: 20,
|
|
rcdAssignment: null,
|
|
terminalDesignation: null,
|
|
voltage: 230,
|
|
controlRequirement: null,
|
|
status: null,
|
|
isReserve: false,
|
|
remark: null,
|
|
protectionDevice: {
|
|
circuitId: "circuit-1",
|
|
type: "LS",
|
|
ratedCurrentA: 16,
|
|
fuseUtilizationCategory: null,
|
|
tripCharacteristic: "B",
|
|
rcdType: null,
|
|
ratedResidualCurrentMa: null,
|
|
},
|
|
deviceRows: [
|
|
{
|
|
id: "row-1",
|
|
circuitId: "circuit-1",
|
|
linkedProjectDeviceId: "device-1",
|
|
sortOrder: 10,
|
|
name: "Steckdose",
|
|
displayName: "Steckdose",
|
|
phaseType: "single_phase",
|
|
connectionKind: null,
|
|
costGroup: null,
|
|
category: null,
|
|
level: null,
|
|
roomId: "room-1",
|
|
roomNumberSnapshot: "0.01",
|
|
roomNameSnapshot: "Büro",
|
|
quantity: 5,
|
|
manualQuantity: 2,
|
|
powerPerUnit: 0.2,
|
|
simultaneityFactor: 1,
|
|
cosPhi: 1,
|
|
remark: null,
|
|
overriddenFields: "[\"displayName\"]",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("distribution-board transfer model", () => {
|
|
it("imports with new ids and without source-project references", () => {
|
|
let nextId = 0;
|
|
const envelope = createDistributionBoardTransferEnvelope(
|
|
sourceBoard(),
|
|
"Quelle",
|
|
"2026-10-29T10:00:00.000Z"
|
|
);
|
|
const { snapshot, adjustments } = prepareDistributionBoardImport(
|
|
parseDistributionBoardTransferEnvelope(JSON.parse(JSON.stringify(envelope))),
|
|
{
|
|
projectId: "project-target",
|
|
name: "UV-EG neu",
|
|
voltageSettings: { singlePhaseVoltageV: 220, threePhaseVoltageV: 380 },
|
|
enabledSupplyTypes: ["AV"],
|
|
},
|
|
() => `new-${++nextId}`
|
|
);
|
|
|
|
const board = snapshot.distributionBoard;
|
|
assert.equal(board.projectId, "project-target");
|
|
assert.equal(board.name, "UV-EG neu");
|
|
assert.equal(board.boardNumber, "UV-1");
|
|
assert.equal(board.simultaneityFactor, 0.8);
|
|
assert.deepEqual(
|
|
[board.floorId, board.supplyType, board.feedingDistributionBoardId],
|
|
[null, null, null]
|
|
);
|
|
assert.equal(snapshot.circuitList.projectId, "project-target");
|
|
const [circuit] = snapshot.circuits;
|
|
assert.ok(circuit.id.startsWith("new-"));
|
|
assert.equal(circuit.equipmentIdentifier, "-2F1.1");
|
|
assert.equal(circuit.cableCrossSection, "3x2,5");
|
|
assert.equal(circuit.voltage, 220);
|
|
assert.equal(circuit.protectionDevice?.circuitId, circuit.id);
|
|
assert.equal(circuit.protectionDevice?.ratedCurrentA, 16);
|
|
const [row] = circuit.deviceRows;
|
|
assert.ok(row.id.startsWith("new-"));
|
|
assert.deepEqual(
|
|
{
|
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
|
overriddenFields: row.overriddenFields,
|
|
roomId: row.roomId,
|
|
roomNumberSnapshot: row.roomNumberSnapshot,
|
|
quantity: row.quantity,
|
|
manualQuantity: row.manualQuantity,
|
|
},
|
|
{
|
|
linkedProjectDeviceId: null,
|
|
overriddenFields: null,
|
|
roomId: null,
|
|
roomNumberSnapshot: "0.01",
|
|
quantity: 2,
|
|
manualQuantity: 2,
|
|
}
|
|
);
|
|
assert.equal(adjustments.length, 6);
|
|
});
|
|
|
|
it("rejects foreign files, unknown fields and broken boards in German", () => {
|
|
const envelope = createDistributionBoardTransferEnvelope(
|
|
sourceBoard(),
|
|
"Quelle",
|
|
"2026-10-29T10:00:00.000Z"
|
|
);
|
|
assert.throws(
|
|
() => parseDistributionBoardTransferEnvelope({ format: "leistungsbilanz.project" }),
|
|
/keine Verteiler-Exportdatei/
|
|
);
|
|
assert.throws(
|
|
() => parseDistributionBoardTransferEnvelope({ ...envelope, extra: 1 }),
|
|
/unbekannte Felder/
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
parseDistributionBoardTransferEnvelope({
|
|
...envelope,
|
|
board: { ...envelope.board, circuits: "kaputt" },
|
|
}),
|
|
/Verteiler in der Exportdatei ist ungültig/
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("distribution-board transfer routes", () => {
|
|
let server: import("node:http").Server;
|
|
let baseUrl = "";
|
|
let user: Headers = {};
|
|
|
|
async function call(method: string, path: string, headers: Headers, body?: unknown) {
|
|
return fetch(baseUrl + path, {
|
|
method,
|
|
headers: { ...headers, "content-type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function json<T>(response: Response, status: number): Promise<T> {
|
|
const text = await response.text();
|
|
assert.equal(response.status, status, text);
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
async function createProject(name: string) {
|
|
return json<{ id: string }>(await call("POST", "/api/projects", user, { name }), 201);
|
|
}
|
|
|
|
async function getRevision(projectId: string) {
|
|
return (
|
|
await json<{ currentRevision: number }>(
|
|
await call("GET", `/api/projects/${projectId}/history`, user),
|
|
200
|
|
)
|
|
).currentRevision;
|
|
}
|
|
|
|
async function loadTree(projectId: string) {
|
|
const [circuitList] = await json<Array<{ id: string }>>(
|
|
await call("GET", `/api/projects/${projectId}/circuit-lists`, user),
|
|
200
|
|
);
|
|
return json<{
|
|
sections: Array<{
|
|
id: string;
|
|
key: string;
|
|
circuits: Array<{
|
|
id: string;
|
|
equipmentIdentifier: string;
|
|
cableType?: string;
|
|
deviceRows: Array<{ id: string; powerPerUnit: number }>;
|
|
}>;
|
|
}>;
|
|
}>(
|
|
await call("GET", `/api/projects/${projectId}/circuit-lists/${circuitList.id}/tree`, user),
|
|
200
|
|
);
|
|
}
|
|
|
|
before(async () => {
|
|
const app = await config.createApp();
|
|
server = app.listen(0, "127.0.0.1");
|
|
await new Promise((resolve) => server.once("listening", resolve));
|
|
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
user = await signIn(baseUrl, "planer-a");
|
|
});
|
|
|
|
after(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
it("exports a board and imports it into another project as one undoable revision", async () => {
|
|
const source = await createProject("Quelle");
|
|
const created = await json<{ distributionBoard: { id: string } }>(
|
|
await call("POST", `/api/projects/${source.id}/distribution-boards`, user, {
|
|
expectedRevision: 0,
|
|
name: "UV-EG",
|
|
floorId: null,
|
|
supplyType: "AV",
|
|
}),
|
|
201
|
|
);
|
|
const section = (await loadTree(source.id)).sections.find(
|
|
(entry) => entry.key === "single_phase"
|
|
);
|
|
assert.ok(section);
|
|
const [circuitList] = await json<Array<{ id: string }>>(
|
|
await call("GET", `/api/projects/${source.id}/circuit-lists`, user),
|
|
200
|
|
);
|
|
await json(
|
|
await call("POST", `/api/projects/${source.id}/commands`, user, {
|
|
expectedRevision: 1,
|
|
command: {
|
|
schemaVersion: 1,
|
|
type: "circuit.insert",
|
|
payload: {
|
|
circuit: {
|
|
...sourceBoard().circuits[0],
|
|
id: "source-circuit",
|
|
circuitListId: circuitList.id,
|
|
sectionId: section.id,
|
|
cableType: null,
|
|
protectionDevice: null,
|
|
deviceRows: [
|
|
{
|
|
...sourceBoard().circuits[0].deviceRows[0],
|
|
id: "source-row",
|
|
circuitId: "source-circuit",
|
|
linkedProjectDeviceId: null,
|
|
roomId: null,
|
|
overriddenFields: null,
|
|
quantity: 2,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
200
|
|
);
|
|
// Cell edits of several entities go through the bulk command.
|
|
await json(
|
|
await call("POST", `/api/projects/${source.id}/commands`, user, {
|
|
expectedRevision: 2,
|
|
command: {
|
|
schemaVersion: 1,
|
|
type: "circuit-list.bulk-update",
|
|
payload: {
|
|
updates: [
|
|
{
|
|
schemaVersion: 1,
|
|
type: "circuit.update",
|
|
payload: {
|
|
circuitId: "source-circuit",
|
|
changes: [{ field: "cableType", value: "NYM-J" }],
|
|
},
|
|
},
|
|
{
|
|
schemaVersion: 1,
|
|
type: "circuit-device-row.update",
|
|
payload: {
|
|
rowId: "source-row",
|
|
changes: [{ field: "powerPerUnit", value: 0.5 }],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
200
|
|
);
|
|
|
|
const exported = await call(
|
|
"GET",
|
|
`/api/projects/${source.id}/distribution-boards/${created.distributionBoard.id}/export`,
|
|
user
|
|
);
|
|
assert.match(
|
|
exported.headers.get("content-disposition") ?? "",
|
|
/UV-EG\.verteiler\.json/
|
|
);
|
|
const transfer = await json<{ format: string }>(exported, 200);
|
|
assert.equal(transfer.format, "leistungsbilanz.distribution-board");
|
|
|
|
const target = await createProject("Ziel");
|
|
const imported = await json<{ adjustments: string[]; distributionBoard: { name: string } }>(
|
|
await call("POST", `/api/projects/${target.id}/distribution-boards/import`, user, {
|
|
expectedRevision: 0,
|
|
name: "UV-EG importiert",
|
|
transfer,
|
|
}),
|
|
201
|
|
);
|
|
assert.equal(imported.distributionBoard.name, "UV-EG importiert");
|
|
assert.deepEqual(imported.adjustments, []);
|
|
const importedCircuit = (await loadTree(target.id)).sections
|
|
.flatMap((entry) => entry.circuits)
|
|
.find((circuit) => circuit.equipmentIdentifier === "-2F1.1");
|
|
assert.ok(importedCircuit);
|
|
assert.notEqual(importedCircuit.id, "source-circuit");
|
|
assert.equal(importedCircuit.cableType, "NYM-J");
|
|
assert.equal(importedCircuit.deviceRows[0].powerPerUnit, 0.5);
|
|
assert.equal(await getRevision(target.id), 1);
|
|
|
|
await json(
|
|
await call("POST", `/api/projects/${target.id}/history/undo`, user, {
|
|
expectedRevision: 1,
|
|
}),
|
|
200
|
|
);
|
|
assert.deepEqual(
|
|
await json(await call("GET", `/api/projects/${target.id}/distribution-boards`, user), 200),
|
|
[]
|
|
);
|
|
});
|
|
|
|
it("rejects unknown boards, invalid files, stale revisions and anonymous requests", async () => {
|
|
const project = await createProject("Fehlerfälle");
|
|
assert.equal(
|
|
(
|
|
await call(
|
|
"GET",
|
|
`/api/projects/${project.id}/distribution-boards/unbekannt/export`,
|
|
user
|
|
)
|
|
).status,
|
|
404
|
|
);
|
|
const importPath = `/api/projects/${project.id}/distribution-boards/import`;
|
|
const invalid = await call("POST", importPath, user, {
|
|
expectedRevision: 0,
|
|
transfer: { format: "leistungsbilanz.project" },
|
|
});
|
|
assert.equal(invalid.status, 400);
|
|
assert.match((await invalid.json()).error, /keine Verteiler-Exportdatei/);
|
|
assert.equal(
|
|
(await call("POST", importPath, user, { expectedRevision: 0, transfer: {}, extra: 1 })).status,
|
|
400
|
|
);
|
|
const envelope = createDistributionBoardTransferEnvelope(
|
|
sourceBoard(),
|
|
"Quelle",
|
|
"2026-10-29T10:00:00.000Z"
|
|
);
|
|
assert.equal(
|
|
(await call("POST", importPath, user, { expectedRevision: 7, transfer: envelope })).status,
|
|
409
|
|
);
|
|
assert.equal(
|
|
(await call("POST", importPath, WRITE_HEADER, { expectedRevision: 0, transfer: envelope })).status,
|
|
401
|
|
);
|
|
assert.equal(
|
|
(
|
|
await call(
|
|
"POST",
|
|
"/api/projects/unbekannt/distribution-boards/import",
|
|
user,
|
|
{ expectedRevision: 0, transfer: envelope }
|
|
)
|
|
).status,
|
|
404
|
|
);
|
|
});
|
|
});
|