Model complete distribution board subtrees
This commit is contained in:
@@ -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<Payload> {
|
||||||
|
schemaVersion: typeof distributionBoardSubtreeCommandSchemaVersion;
|
||||||
|
type: typeof distributionBoardInsertSubtreeCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardDeleteSubtreeProjectCommand
|
||||||
|
extends SerializedProjectCommand<Payload> {
|
||||||
|
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<unknown>
|
||||||
|
): asserts command is DistributionBoardInsertSubtreeProjectCommand {
|
||||||
|
assertCommand(command, distributionBoardInsertSubtreeCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDistributionBoardDeleteSubtreeProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is DistributionBoardDeleteSubtreeProjectCommand {
|
||||||
|
assertCommand(command, distributionBoardDeleteSubtreeCommandType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
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<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
const sectionKeys = new Set<string>();
|
||||||
|
const sectionPrefixes = new Set<string>();
|
||||||
|
const categoryGroups = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
const componentIds = new Set<string>();
|
||||||
|
const fixedRoles = new Set<string>();
|
||||||
|
const groupRoles = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
const deviceRowIds = new Set<string>();
|
||||||
|
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<string>
|
||||||
|
): 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<string, string>,
|
||||||
|
sourceId: string
|
||||||
|
) {
|
||||||
|
const id = ids.get(sourceId);
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("Distribution-board copy reference is invalid.");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerEquipmentIdentifier(
|
||||||
|
identifiers: Set<string>,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
registerUnique(
|
||||||
|
identifiers,
|
||||||
|
value.trim().toLocaleLowerCase("de"),
|
||||||
|
"equipment identifier"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerUnique(
|
||||||
|
values: Set<string>,
|
||||||
|
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<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
updateDistributionBoardComponentSnapshot,
|
updateDistributionBoardComponentSnapshot,
|
||||||
} from "../src/frontend/utils/distribution-board-component-editing.js";
|
} from "../src/frontend/utils/distribution-board-component-editing.js";
|
||||||
import "./distribution-board-component-structure-project-command.repository.test.js";
|
import "./distribution-board-component-structure-project-command.repository.test.js";
|
||||||
|
import "./distribution-board-subtree.test.js";
|
||||||
|
|
||||||
describe("distribution board component catalog", () => {
|
describe("distribution board component catalog", () => {
|
||||||
it("defines the agreed component roles and placement zones", () => {
|
it("defines the agreed component roles and placement zones", () => {
|
||||||
|
|||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user