leistungsbilanz-ts/tests/project-state-snapshot.test.ts
Grovy311 94688d7a8c Add print view, board info card and document logo upload
- 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>
2026-09-25 19:31:36 +02:00

375 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() {
return {
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: "INT-1",
externalProjectNumber: null,
buildingOwner: null,
description: null,
isPublicBuilding: true,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"SV",
"EV",
"USV",
"MSR",
"SiBe",
],
},
distributionBoards: [],
externalCsvConfiguration: null,
externalModel: {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
},
circuitLists: [],
circuitSections: [],
distributionBoardComponents: [],
circuitProtectionDevices: [],
distributionBoardComponentProtectionDevices: [],
circuits: [],
projectDevices: [],
floors: [],
rooms: [],
};
}
function protectedSnapshot() {
return {
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
boardNumber: "UV 1.1" as string | null,
location: "Raum 003 Technik" as string | null,
fedFrom: "HV 0.1" as string | null,
remark: null as string | null,
},
],
circuitLists: [
{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
],
circuitSections: [
{
id: "section-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting" as const,
groupNumber: 1,
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1.1",
displayName: "Reserve",
sortOrder: 10,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
distributionBoardComponents: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
circuitProtectionDevices: [
{
circuitId: "circuit-1",
type: "LS" as const,
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B" as const,
rcdType: null,
ratedResidualCurrentMa: null,
},
],
distributionBoardComponentProtectionDevices: [
{
componentId: "component-1",
type: "FI" as const,
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
],
};
}
describe("project state snapshot model", () => {
it("round-trips the current logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
assert.deepEqual(
deserializeProjectStateSnapshot(serializeProjectStateSnapshot(snapshot)),
snapshot
);
});
it("upgrades the supported baseline snapshot with a safe building default", () => {
const baseline = structuredClone(minimalSnapshot());
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(baseline);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.isPublicBuilding, false);
assert.equal(upgraded.externalCsvConfiguration, null);
});
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration;
delete (previous as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
});
it("upgrades snapshot version 3 with an empty external model", () => {
const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
});
it("upgrades snapshot version 5 with empty distribution-board info", () => {
const previous = protectedSnapshot();
previous.schemaVersion = 5 as typeof previous.schemaVersion;
for (const board of previous.distributionBoards) {
const record = board as Record<string, unknown>;
delete record.boardNumber;
delete record.location;
delete record.fedFrom;
delete record.remark;
}
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(
{
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
location: upgraded.distributionBoards[0]?.location,
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
remark: upgraded.distributionBoards[0]?.remark,
},
{ boardNumber: null, location: null, fedFrom: null, remark: null }
);
});
it("keeps distribution-board info values and rejects them in older versions", () => {
const parsed = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
const mislabeled = protectedSnapshot();
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
});
it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot();
previous.schemaVersion = 4 as typeof previous.schemaVersion;
for (const board of previous.distributionBoards) {
const record = board as Record<string, unknown>;
delete record.boardNumber;
delete record.location;
delete record.fedFrom;
delete record.remark;
}
previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 3,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 1,
remark: null,
overriddenFields: null,
}];
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.circuits[0]?.deviceRows[0]?.manualQuantity, 3);
});
it("rejects malformed baseline and unknown snapshot shapes", () => {
const outdated = structuredClone(minimalSnapshot());
delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() =>
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 })
);
});
it("validates component, protection and equipment relations", () => {
const snapshot = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(snapshot.circuitProtectionDevices[0]?.tripCharacteristic, "B");
assert.equal(
snapshot.distributionBoardComponentProtectionDevices[0]
?.ratedResidualCurrentMa,
30
);
const duplicateBmk = protectedSnapshot();
duplicateBmk.distributionBoardComponents[0]!.equipmentIdentifier =
" -1f1.1 ";
assert.throws(
() => parseProjectStateSnapshot(duplicateBmk),
/duplicate equipment identifiers/
);
const invalidProtection = protectedSnapshot();
invalidProtection.circuitProtectionDevices[0] = {
...invalidProtection.circuitProtectionDevices[0]!,
ratedCurrentA: 35,
};
assert.throws(
() => parseProjectStateSnapshot(invalidProtection),
/protection-device configuration is invalid/
);
});
it("rejects duplicate ids and invalid ownership", () => {
const duplicate = protectedSnapshot();
duplicate.distributionBoards.push({
...duplicate.distributionBoards[0]!,
name: "UV 2",
});
assert.throws(
() => parseProjectStateSnapshot(duplicate),
/duplicate distribution board ids/
);
const wrongOwner = minimalSnapshot();
wrongOwner.floors.push({
id: "floor-1",
projectId: "project-2",
name: "EG",
sortOrder: 10,
});
assert.throws(
() => parseProjectStateSnapshot(wrongOwner),
/floor belongs to a different project/
);
});
});
describe("named project snapshot request", () => {
it("normalizes bounded snapshot metadata", () => {
assert.deepEqual(
createNamedProjectSnapshotSchema.parse({
expectedRevision: 12,
name: " Freigabe ",
description: " Stand vor Änderung ",
}),
{
expectedRevision: 12,
name: "Freigabe",
description: "Stand vor Änderung",
}
);
});
it("rejects stale-shaped and excessive input", () => {
assert.throws(() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: -1,
name: "",
unexpected: true,
})
);
assert.throws(() =>
createNamedProjectSnapshotSchema.parse({
expectedRevision: 0,
name: "x".repeat(201),
})
);
});
});