diff --git a/src/domain/models/distribution-board-subtree-project-command.model.ts b/src/domain/models/distribution-board-subtree-project-command.model.ts new file mode 100644 index 0000000..9acfe33 --- /dev/null +++ b/src/domain/models/distribution-board-subtree-project-command.model.ts @@ -0,0 +1,86 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; +import { + assertDistributionBoardSubtreeSnapshot, + type DistributionBoardSubtreeSnapshot, +} from "./distribution-board-subtree-snapshot.model.js"; + +export const distributionBoardInsertSubtreeCommandType = + "distribution-board.insert-subtree" as const; +export const distributionBoardDeleteSubtreeCommandType = + "distribution-board.delete-subtree" as const; +export const distributionBoardSubtreeCommandSchemaVersion = 1 as const; + +interface Payload { + snapshot: DistributionBoardSubtreeSnapshot; +} + +export interface DistributionBoardInsertSubtreeProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardSubtreeCommandSchemaVersion; + type: typeof distributionBoardInsertSubtreeCommandType; +} + +export interface DistributionBoardDeleteSubtreeProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardSubtreeCommandSchemaVersion; + type: typeof distributionBoardDeleteSubtreeCommandType; +} + +export type DistributionBoardSubtreeProjectCommand = + | DistributionBoardInsertSubtreeProjectCommand + | DistributionBoardDeleteSubtreeProjectCommand; + +export function createDistributionBoardInsertSubtreeProjectCommand( + snapshot: DistributionBoardSubtreeSnapshot +): DistributionBoardInsertSubtreeProjectCommand { + const command: DistributionBoardInsertSubtreeProjectCommand = { + schemaVersion: distributionBoardSubtreeCommandSchemaVersion, + type: distributionBoardInsertSubtreeCommandType, + payload: { snapshot }, + }; + assertDistributionBoardInsertSubtreeProjectCommand(command); + return command; +} + +export function createDistributionBoardDeleteSubtreeProjectCommand( + snapshot: DistributionBoardSubtreeSnapshot +): DistributionBoardDeleteSubtreeProjectCommand { + const command: DistributionBoardDeleteSubtreeProjectCommand = { + schemaVersion: distributionBoardSubtreeCommandSchemaVersion, + type: distributionBoardDeleteSubtreeCommandType, + payload: { snapshot }, + }; + assertDistributionBoardDeleteSubtreeProjectCommand(command); + return command; +} + +export function assertDistributionBoardInsertSubtreeProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardInsertSubtreeProjectCommand { + assertCommand(command, distributionBoardInsertSubtreeCommandType); +} + +export function assertDistributionBoardDeleteSubtreeProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardDeleteSubtreeProjectCommand { + assertCommand(command, distributionBoardDeleteSubtreeCommandType); +} + +function assertCommand( + command: SerializedProjectCommand, + type: DistributionBoardSubtreeProjectCommand["type"] +) { + if ( + command.schemaVersion !== distributionBoardSubtreeCommandSchemaVersion || + command.type !== type || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported distribution-board subtree command."); + } + assertDistributionBoardSubtreeSnapshot(command.payload.snapshot); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/models/distribution-board-subtree-snapshot.model.ts b/src/domain/models/distribution-board-subtree-snapshot.model.ts new file mode 100644 index 0000000..ba55059 --- /dev/null +++ b/src/domain/models/distribution-board-subtree-snapshot.model.ts @@ -0,0 +1,466 @@ +import crypto from "node:crypto"; +import { + circuitGroupCategories, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group.js"; +import { + distributionBoardComponentPlacements, + distributionBoardComponentRoles, + type DistributionBoardComponentPlacement, + type DistributionBoardComponentRole, +} from "../../shared/constants/distribution-board-component.js"; +import { + distributionBoardSupplyTypes, + type DistributionBoardSupplyType, +} from "../../shared/constants/distribution-board.js"; +import type { + BreakerTripCharacteristic, + FuseUtilizationCategory, + ProtectionDeviceType, + RcdType, +} from "../../shared/constants/protection-device.js"; +import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; +import { + assertCircuitInsertProjectCommand, + circuitInsertCommandType, + circuitStructureCommandSchemaVersion, + type CircuitSnapshot, +} from "./circuit-structure-project-command.model.js"; + +export interface DistributionBoardSubtreeSectionSnapshot { + id: string; + circuitListId: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; + category: CircuitGroupCategory | null; + groupNumber: number | null; +} + +export interface DistributionBoardSubtreeComponentProtectionSnapshot { + componentId: string; + type: ProtectionDeviceType; + ratedCurrentA: number; + fuseUtilizationCategory: FuseUtilizationCategory | null; + tripCharacteristic: BreakerTripCharacteristic | null; + rcdType: RcdType | null; + ratedResidualCurrentMa: number | null; +} + +export interface DistributionBoardSubtreeComponentSnapshot { + component: { + id: string; + circuitListId: string; + sectionId: string | null; + equipmentIdentifier: string; + name: string; + role: DistributionBoardComponentRole; + placement: DistributionBoardComponentPlacement; + sortOrder: number; + }; + protectionDevice: + | DistributionBoardSubtreeComponentProtectionSnapshot + | null; +} + +export interface DistributionBoardSubtreeSnapshot { + distributionBoard: { + id: string; + projectId: string; + name: string; + floorId: string | null; + supplyType: DistributionBoardSupplyType | null; + simultaneityFactor: number; + }; + circuitList: { + id: string; + projectId: string; + distributionBoardId: string; + name: string; + }; + sections: DistributionBoardSubtreeSectionSnapshot[]; + components: DistributionBoardSubtreeComponentSnapshot[]; + circuits: CircuitSnapshot[]; +} + +export function cloneDistributionBoardSubtree( + source: DistributionBoardSubtreeSnapshot, + name: string, + createId: () => string = () => crypto.randomUUID() +): DistributionBoardSubtreeSnapshot { + assertDistributionBoardSubtreeSnapshot(source); + const normalizedName = name.trim(); + assertNonEmptyString(normalizedName, "name"); + + const distributionBoardId = createId(); + const circuitListId = distributionBoardId; + const sectionIds = new Map( + source.sections.map((section) => [section.id, createId()]) + ); + const circuitIds = new Map( + source.circuits.map((circuit) => [circuit.id, createId()]) + ); + const componentIds = new Map( + source.components.map(({ component }) => [component.id, createId()]) + ); + + const clone: DistributionBoardSubtreeSnapshot = { + distributionBoard: { + ...source.distributionBoard, + id: distributionBoardId, + name: normalizedName, + }, + circuitList: { + ...source.circuitList, + id: circuitListId, + distributionBoardId, + name: `${normalizedName} Stromkreisliste`, + }, + sections: source.sections.map((section) => ({ + ...section, + id: requiredMappedId(sectionIds, section.id), + circuitListId, + })), + components: source.components.map( + ({ component, protectionDevice }) => { + const componentId = requiredMappedId( + componentIds, + component.id + ); + return { + component: { + ...component, + id: componentId, + circuitListId, + sectionId: + component.sectionId === null + ? null + : requiredMappedId(sectionIds, component.sectionId), + }, + protectionDevice: + protectionDevice === null + ? null + : { ...protectionDevice, componentId }, + }; + } + ), + circuits: source.circuits.map((circuit) => { + const circuitId = requiredMappedId(circuitIds, circuit.id); + return { + ...circuit, + id: circuitId, + circuitListId, + sectionId: requiredMappedId(sectionIds, circuit.sectionId), + deviceRows: circuit.deviceRows.map((row) => ({ + ...row, + id: createId(), + circuitId, + legacyConsumerId: null, + })), + protectionDevice: + circuit.protectionDevice === null || + circuit.protectionDevice === undefined + ? null + : { ...circuit.protectionDevice, circuitId }, + }; + }), + }; + assertDistributionBoardSubtreeSnapshot(clone); + return clone; +} + +export function assertDistributionBoardSubtreeSnapshot( + value: unknown +): asserts value is DistributionBoardSubtreeSnapshot { + if ( + !isPlainObject(value) || + Object.keys(value).length !== 5 || + !isPlainObject(value.distributionBoard) || + !isPlainObject(value.circuitList) || + !Array.isArray(value.sections) || + !Array.isArray(value.components) || + !Array.isArray(value.circuits) + ) { + throw new Error("Distribution-board subtree snapshot is invalid."); + } + const board = value.distributionBoard; + const list = value.circuitList; + for (const field of ["id", "projectId", "name"] as const) { + assertNonEmptyString(board[field], `distributionBoard.${field}`); + } + assertNullableString(board.floorId, "distributionBoard.floorId"); + if ( + board.supplyType !== null && + !distributionBoardSupplyTypes.includes( + board.supplyType as DistributionBoardSupplyType + ) + ) { + throw new Error("Distribution-board supply type is invalid."); + } + assertFiniteNumber( + board.simultaneityFactor, + "distributionBoard.simultaneityFactor" + ); + if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) { + throw new Error("Distribution-board simultaneity factor is invalid."); + } + for (const field of [ + "id", + "projectId", + "distributionBoardId", + "name", + ] as const) { + assertNonEmptyString(list[field], `circuitList.${field}`); + } + const circuitListId = list.id; + assertNonEmptyString(circuitListId, "circuitList.id"); + if ( + list.projectId !== board.projectId || + list.distributionBoardId !== board.id + ) { + throw new Error("Distribution-board circuit list ownership is invalid."); + } + + const sectionIds = new Set(); + const sectionKeys = new Set(); + const sectionPrefixes = new Set(); + const categoryGroups = new Set(); + for (const section of value.sections) { + assertSection(section, circuitListId); + registerUnique(sectionIds, section.id, "circuit-section id"); + registerUnique(sectionKeys, section.key, "circuit-section key"); + registerUnique(sectionPrefixes, section.prefix, "circuit-section prefix"); + if (section.category !== null && section.groupNumber !== null) { + registerUnique( + categoryGroups, + `${section.category}:${section.groupNumber}`, + "circuit-group number" + ); + } + } + + const equipmentIdentifiers = new Set(); + const componentIds = new Set(); + const fixedRoles = new Set(); + const groupRoles = new Set(); + for (const entry of value.components) { + assertComponent(entry, circuitListId, sectionIds); + const component = entry.component; + registerUnique(componentIds, component.id, "component id"); + registerEquipmentIdentifier( + equipmentIdentifiers, + component.equipmentIdentifier + ); + if ( + component.role === "main_switch" || + component.role === "surge_protective_device" + ) { + registerUnique(fixedRoles, component.role, "fixed component role"); + } + if ( + component.role === "group_upstream_protection" || + component.role === "group_residual_current_protection" + ) { + registerUnique( + groupRoles, + `${component.sectionId}:${component.role}`, + "group component role" + ); + } + } + + const circuitIds = new Set(); + const deviceRowIds = new Set(); + for (const circuit of value.circuits) { + assertCircuitInsertProjectCommand({ + schemaVersion: circuitStructureCommandSchemaVersion, + type: circuitInsertCommandType, + payload: { circuit }, + }); + if ( + circuit.circuitListId !== circuitListId || + !sectionIds.has(circuit.sectionId) || + !("protectionDevice" in circuit) + ) { + throw new Error("Distribution-board circuit ownership is invalid."); + } + registerUnique(circuitIds, circuit.id, "circuit id"); + registerEquipmentIdentifier( + equipmentIdentifiers, + circuit.equipmentIdentifier + ); + for (const row of circuit.deviceRows) { + registerUnique(deviceRowIds, row.id, "device-row id"); + } + } +} + +function assertSection( + value: unknown, + circuitListId: string +): asserts value is DistributionBoardSubtreeSectionSnapshot { + if (!isPlainObject(value) || Object.keys(value).length !== 8) { + throw new Error("Distribution-board circuit section is invalid."); + } + for (const field of ["id", "circuitListId", "key", "displayName", "prefix"] as const) { + assertNonEmptyString(value[field], `section.${field}`); + } + assertFiniteNumber(value.sortOrder, "section.sortOrder"); + if (value.circuitListId !== circuitListId) { + throw new Error("Distribution-board section belongs to another list."); + } + if ( + value.category === null + ? value.groupNumber !== null + : !circuitGroupCategories.includes( + value.category as CircuitGroupCategory + ) || + !Number.isInteger(value.groupNumber) || + (value.groupNumber as number) <= 0 + ) { + throw new Error("Distribution-board circuit group is invalid."); + } +} + +function assertComponent( + value: unknown, + circuitListId: string, + sectionIds: ReadonlySet +): asserts value is DistributionBoardSubtreeComponentSnapshot { + if ( + !isPlainObject(value) || + Object.keys(value).length !== 2 || + !isPlainObject(value.component) + ) { + throw new Error("Distribution-board component snapshot is invalid."); + } + const component = value.component; + for (const field of ["id", "circuitListId", "equipmentIdentifier", "name"] as const) { + assertNonEmptyString(component[field], `component.${field}`); + } + if ( + component.circuitListId !== circuitListId || + !distributionBoardComponentRoles.includes( + component.role as DistributionBoardComponentRole + ) || + !distributionBoardComponentPlacements.includes( + component.placement as DistributionBoardComponentPlacement + ) + ) { + throw new Error("Distribution-board component values are invalid."); + } + assertFiniteNumber(component.sortOrder, "component.sortOrder"); + assertNullableString(component.sectionId, "component.sectionId"); + const fixed = + component.role === "main_switch" || + component.role === "surge_protective_device"; + const grouped = + component.role === "group_upstream_protection" || + component.role === "group_residual_current_protection"; + if ( + (fixed && + (component.placement !== "header" || component.sectionId !== null)) || + (grouped && + (component.placement !== "group" || + typeof component.sectionId !== "string" || + !sectionIds.has(component.sectionId))) || + (component.role === "auxiliary" && + (component.placement !== "footer" || component.sectionId !== null)) + ) { + throw new Error("Distribution-board component placement is invalid."); + } + if (value.protectionDevice === null) { + if (grouped) { + throw new Error("Group protection component lacks protection data."); + } + return; + } + if (!grouped || !isPlainObject(value.protectionDevice)) { + throw new Error("Component protection data is invalid."); + } + const protection = value.protectionDevice; + if ( + protection.componentId !== component.id || + !protectionDeviceConfigurationSchema.safeParse({ + type: protection.type, + ratedCurrentA: protection.ratedCurrentA, + ...(protection.fuseUtilizationCategory === null + ? {} + : { fuseUtilizationCategory: protection.fuseUtilizationCategory }), + ...(protection.tripCharacteristic === null + ? {} + : { tripCharacteristic: protection.tripCharacteristic }), + ...(protection.rcdType === null ? {} : { rcdType: protection.rcdType }), + ...(protection.ratedResidualCurrentMa === null + ? {} + : { ratedResidualCurrentMa: protection.ratedResidualCurrentMa }), + }).success + ) { + throw new Error("Component protection configuration is invalid."); + } +} + +function requiredMappedId( + ids: ReadonlyMap, + sourceId: string +) { + const id = ids.get(sourceId); + if (!id) { + throw new Error("Distribution-board copy reference is invalid."); + } + return id; +} + +function registerEquipmentIdentifier( + identifiers: Set, + value: string +) { + registerUnique( + identifiers, + value.trim().toLocaleLowerCase("de"), + "equipment identifier" + ); +} + +function registerUnique( + values: Set, + value: string, + label: string +) { + if (values.has(value)) { + throw new Error(`Distribution-board subtree contains duplicate ${label}.`); + } + values.add(value); +} + +function assertNonEmptyString( + value: unknown, + field: string +): asserts value is string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function assertNullableString( + value: unknown, + field: string +): asserts value is string | null { + if (value !== null) { + assertNonEmptyString(value, field); + } +} + +function assertFiniteNumber( + value: unknown, + field: string +): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${field} must be finite.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/tests/distribution-board-component.test.ts b/tests/distribution-board-component.test.ts index 9396675..471d11a 100644 --- a/tests/distribution-board-component.test.ts +++ b/tests/distribution-board-component.test.ts @@ -15,6 +15,7 @@ import { updateDistributionBoardComponentSnapshot, } from "../src/frontend/utils/distribution-board-component-editing.js"; import "./distribution-board-component-structure-project-command.repository.test.js"; +import "./distribution-board-subtree.test.js"; describe("distribution board component catalog", () => { it("defines the agreed component roles and placement zones", () => { diff --git a/tests/distribution-board-subtree.test.ts b/tests/distribution-board-subtree.test.ts new file mode 100644 index 0000000..3faaa12 --- /dev/null +++ b/tests/distribution-board-subtree.test.ts @@ -0,0 +1,200 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + assertDistributionBoardSubtreeSnapshot, + cloneDistributionBoardSubtree, + type DistributionBoardSubtreeSnapshot, +} from "../src/domain/models/distribution-board-subtree-snapshot.model.js"; +import { + createDistributionBoardDeleteSubtreeProjectCommand, + createDistributionBoardInsertSubtreeProjectCommand, +} from "../src/domain/models/distribution-board-subtree-project-command.model.js"; + +function createSnapshot(): DistributionBoardSubtreeSnapshot { + return { + distributionBoard: { + id: "board-1", + projectId: "project-1", + name: "UV 1", + floorId: "floor-1", + supplyType: "AV", + simultaneityFactor: 0.8, + }, + circuitList: { + id: "list-1", + projectId: "project-1", + distributionBoardId: "board-1", + name: "UV 1 Stromkreisliste", + }, + sections: [ + { + id: "section-1", + circuitListId: "list-1", + key: "lighting", + displayName: "Beleuchtung 1", + prefix: "-1F1.", + sortOrder: 10, + category: "lighting", + groupNumber: 1, + }, + ], + components: [ + { + component: { + id: "main-1", + circuitListId: "list-1", + sectionId: null, + equipmentIdentifier: "-Q0", + name: "Hauptschalter", + role: "main_switch", + placement: "header", + sortOrder: 10, + }, + protectionDevice: null, + }, + { + component: { + id: "rcd-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 20, + }, + protectionDevice: { + componentId: "rcd-1", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }, + ], + circuits: [ + { + id: "circuit-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1F1.1", + displayName: "Licht", + sortOrder: 10, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + cableType: "NYM-J", + cableCrossSection: "3x1,5 mm²", + cableLength: 10, + rcdAssignment: null, + terminalDesignation: null, + voltage: 230, + controlRequirement: null, + status: null, + isReserve: false, + remark: null, + deviceRows: [ + { + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + legacyConsumerId: "legacy-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: "room-1", + roomNumberSnapshot: "1.01", + roomNameSnapshot: "Büro", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + cosPhi: 0.9, + remark: null, + overriddenFields: null, + }, + ], + protectionDevice: { + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 10, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, + }, + }, + ], + }; +} + +describe("distribution-board subtree snapshot", () => { + it("validates complete populated board structures and commands", () => { + const snapshot = createSnapshot(); + assert.doesNotThrow(() => + assertDistributionBoardSubtreeSnapshot(snapshot) + ); + assert.equal( + createDistributionBoardInsertSubtreeProjectCommand(snapshot).type, + "distribution-board.insert-subtree" + ); + assert.equal( + createDistributionBoardDeleteSubtreeProjectCommand(snapshot).type, + "distribution-board.delete-subtree" + ); + }); + + it("clones every owned id while preserving project links and values", () => { + const ids = Array.from({ length: 6 }, (_, index) => `new-${index + 1}`); + const clone = cloneDistributionBoardSubtree( + createSnapshot(), + "UV 1 Kopie", + () => ids.shift()! + ); + + assert.equal(clone.distributionBoard.id, "new-1"); + assert.equal(clone.circuitList.id, "new-1"); + assert.equal(clone.distributionBoard.name, "UV 1 Kopie"); + assert.equal(clone.circuitList.name, "UV 1 Kopie Stromkreisliste"); + assert.equal(clone.sections[0].id, "new-2"); + assert.equal(clone.circuits[0].id, "new-3"); + assert.equal(clone.components[0].component.id, "new-4"); + assert.equal(clone.components[1].component.id, "new-5"); + assert.equal(clone.components[1].component.sectionId, "new-2"); + assert.equal(clone.components[1].protectionDevice?.componentId, "new-5"); + assert.equal(clone.circuits[0].sectionId, "new-2"); + assert.equal(clone.circuits[0].deviceRows[0].id, "new-6"); + assert.equal(clone.circuits[0].deviceRows[0].circuitId, "new-3"); + assert.equal( + clone.circuits[0].deviceRows[0].linkedProjectDeviceId, + "device-1" + ); + assert.equal(clone.circuits[0].deviceRows[0].roomId, "room-1"); + assert.equal(clone.circuits[0].deviceRows[0].legacyConsumerId, null); + assert.equal(clone.circuits[0].protectionDevice?.circuitId, "new-3"); + assert.doesNotThrow(() => assertDistributionBoardSubtreeSnapshot(clone)); + }); + + it("rejects broken ownership and duplicate equipment identifiers", () => { + const brokenOwnership = createSnapshot(); + brokenOwnership.circuits[0].sectionId = "missing"; + assert.throws( + () => assertDistributionBoardSubtreeSnapshot(brokenOwnership), + /ownership/ + ); + + const duplicateIdentifier = createSnapshot(); + duplicateIdentifier.components[0].component.equipmentIdentifier = + " -1f1.1 "; + assert.throws( + () => assertDistributionBoardSubtreeSnapshot(duplicateIdentifier), + /duplicate equipment identifier/ + ); + }); +});