forked from jappel/leistungsbilanz-ts
- Seitenansicht (/projects/:projectId/print): tabular cover page, board overview with supply structure and power sums, one plan per board with a repeating title block; PDF via the browser print dialog with named @page rules (A4 portrait cover, A4/A3 landscape plans, page x of y). - Distribution-board info card (board number, location, fed-from, remark) in the board edit dialog, persisted through the additively extended distribution-board.update command (migration 0008, snapshot schema 6, legacy subtree commands normalized). - Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project revisions and snapshots. - fed-from stays free text; the overview hierarchy and cumulated sums are preliminary until the feed link is modeled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
4.9 KiB
TypeScript
176 lines
4.9 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import {
|
||
buildPreliminaryBoardHierarchy,
|
||
formatProtectionDevice,
|
||
summarizePrintBoard,
|
||
} from "../src/frontend/print/print-document-model.js";
|
||
import type {
|
||
CircuitTreeResponseDto,
|
||
DistributionBoardDto,
|
||
} from "../src/frontend/types.js";
|
||
|
||
function board(
|
||
id: string,
|
||
info: Partial<DistributionBoardDto> = {}
|
||
): DistributionBoardDto {
|
||
return {
|
||
id,
|
||
projectId: "project-1",
|
||
name: id,
|
||
floorId: null,
|
||
supplyType: "AV",
|
||
simultaneityFactor: 1,
|
||
boardNumber: null,
|
||
location: null,
|
||
fedFrom: null,
|
||
remark: null,
|
||
...info,
|
||
};
|
||
}
|
||
|
||
function tree(
|
||
rows: Array<{
|
||
quantity: number;
|
||
powerPerUnit: number;
|
||
simultaneityFactor: number;
|
||
cosPhi?: number;
|
||
}>,
|
||
boardSimultaneityFactor = 1
|
||
): CircuitTreeResponseDto {
|
||
const deviceRows = rows.map((row, index) => ({
|
||
id: `row-${index}`,
|
||
sortOrder: index,
|
||
name: "Gerät",
|
||
displayName: "Gerät",
|
||
manualQuantity: row.quantity,
|
||
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
|
||
...row,
|
||
}));
|
||
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
|
||
return {
|
||
circuitListId: "list",
|
||
currentRevision: 1,
|
||
singlePhaseVoltageV: 230,
|
||
threePhaseVoltageV: 400,
|
||
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
|
||
distributionBoardTotalPower: total,
|
||
distributionBoardTotalPowerWithSimultaneityFactor:
|
||
total * boardSimultaneityFactor,
|
||
headerComponents: [],
|
||
footerComponents: [],
|
||
sections: [
|
||
{
|
||
id: "section",
|
||
key: "single_phase",
|
||
displayName: "1-phasig 1",
|
||
prefix: "-2F1.",
|
||
sortOrder: 1,
|
||
sectionTotalPower: total,
|
||
components: [],
|
||
circuits: [
|
||
{
|
||
id: "circuit",
|
||
circuitListId: "list",
|
||
sectionId: "section",
|
||
equipmentIdentifier: "-2F1.1",
|
||
sortOrder: 1,
|
||
isReserve: false,
|
||
circuitTotalPower: total,
|
||
deviceRows,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
function summary(
|
||
id: string,
|
||
info: Partial<DistributionBoardDto>,
|
||
demandKw: number
|
||
) {
|
||
return summarizePrintBoard({
|
||
board: board(id, info),
|
||
circuitListId: id,
|
||
floorName: null,
|
||
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
|
||
});
|
||
}
|
||
|
||
describe("print document model", () => {
|
||
it("derives installed, device and demand power and the design current", () => {
|
||
const result = summarizePrintBoard({
|
||
board: board("uv"),
|
||
circuitListId: "uv",
|
||
floorName: "EG",
|
||
tree: tree(
|
||
[
|
||
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
|
||
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
|
||
],
|
||
0.5
|
||
),
|
||
});
|
||
assert.equal(result.circuitCount, 1);
|
||
assert.equal(result.installedPowerKw, 14);
|
||
assert.equal(result.devicePowerKw, 10);
|
||
assert.equal(result.demandPowerKw, 5);
|
||
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
|
||
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
|
||
});
|
||
|
||
it("nests boards by matching free-text feed and sums downstream demand", () => {
|
||
const entries = buildPreliminaryBoardHierarchy([
|
||
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
|
||
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
|
||
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
|
||
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
|
||
]);
|
||
assert.deepEqual(
|
||
entries.map((entry) => [
|
||
entry.summary.board.id,
|
||
entry.depth,
|
||
entry.parentBoardId,
|
||
entry.cumulatedDemandPowerKw,
|
||
]),
|
||
[
|
||
["hv", 0, null, 16],
|
||
["uv-a", 1, "hv", 6],
|
||
["uv-b", 2, "uv-a", 2],
|
||
["uv-c", 0, null, 1],
|
||
]
|
||
);
|
||
});
|
||
|
||
it("keeps boards with cyclic feed texts at the top level", () => {
|
||
const entries = buildPreliminaryBoardHierarchy([
|
||
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
|
||
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
|
||
]);
|
||
assert.equal(entries.length, 2);
|
||
assert.ok(entries.some((entry) => entry.parentBoardId === null));
|
||
assert.equal(
|
||
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
|
||
2
|
||
);
|
||
});
|
||
|
||
it("formats protection devices for the plan table", () => {
|
||
assert.equal(
|
||
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
|
||
"LS B 16 A"
|
||
);
|
||
assert.equal(
|
||
formatProtectionDevice({
|
||
type: "FI_LS",
|
||
ratedCurrentA: 16,
|
||
tripCharacteristic: "B",
|
||
rcdType: "A",
|
||
ratedResidualCurrentMa: 30,
|
||
}),
|
||
"FI/LS B 16 A, 30 mA Typ A"
|
||
);
|
||
assert.equal(formatProtectionDevice(undefined), null);
|
||
});
|
||
});
|