From 012308984d2268f516d8e2c2de781874b2972848 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 19:24:28 +0200 Subject: [PATCH] Include components in project snapshots --- AGENTS.md | 5 + docs/current-architecture.md | 17 +- docs/spec/08-current-product-backlog.md | 2 +- ...-board-components-and-protection-groups.md | 18 +- .../project-snapshot.repository.ts | 3 + ...roject-state-restore-command.repository.ts | 38 ++ .../project-state-snapshot.persistence.ts | 59 +++ .../project-transfer.repository.ts | 18 + .../models/project-state-snapshot.model.ts | 417 ++++++++++++++++-- src/domain/models/project-transfer.model.ts | 33 ++ tests/project-snapshot.repository.test.ts | 73 +++ ...t-state-restore-command.repository.test.ts | 50 +++ tests/project-state-snapshot.test.ts | 228 +++++++++- 13 files changed, 915 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 69de0d1..25785c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,6 +255,11 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. +Snapshot schema version 7 also contains circuit-group identity, +distribution-board components and the separate circuit/component protection +records. Versions 1 through 6 remain readable. Portable duplicate imports +remap component ids and protection-owner references together with the existing +project graph. The central revision boundary creates an automatic logical snapshot after each 25 new revisions and retains only the newest 12 automatic snapshots per project. Named snapshots are never removed by this retention policy. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 6a90e0f..eca8dd4 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -261,8 +261,9 @@ mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace` läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, -Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu -zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- +Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und +Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt +die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die Projektübersicht verwendet dafür den separaten Collection-Endpunkt @@ -290,6 +291,13 @@ Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als Projektauswahl hochgestuft. +Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer, +Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture, +benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide +JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter +bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch +Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer +und bestehende flache Schutzangaben unverändert erhalten. `project-floor.insert` und `project-room.insert` versionieren die Anlage von Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur @@ -342,8 +350,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert. Ein triggergeführtes Register erzwingt bereits eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und - Verteilerkomponenten. Snapshot-, Transfer-, Command- und UI-Integration - folgen in den nächsten abgegrenzten Arbeitspaketen. + Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden + Snapshot-Schema 7; Command- und UI-Integration folgen in den nächsten + abgegrenzten Arbeitspaketen. PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 2426c2f..0ee044c 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -16,7 +16,7 @@ requirements and intended sequencing, not proof of implementation. BMK rules. - [x] Phase B1: additive relational schema, group backfill, component/protection tables and shared BMK uniqueness. -- [ ] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility +- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility mapping. - [ ] Phase C: persistent commands and new-board defaults. - [ ] Phase D: group numbering, moves and destructive operations. diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index ed19690..5cd077d 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -545,8 +545,7 @@ Acceptance: ### B. Persistence and Compatibility Model -Status: In progress. Relational foundation B1 is complete; logical -snapshot/transfer integration B2 is pending. +Status: Complete. - add group category and group number - add distribution-board component persistence @@ -570,6 +569,21 @@ Implemented in B1: - newly created legacy-compatible board structures receive the same initial group identity while their persisted command format remains unchanged +Implemented in B2: + +- snapshot schema version 7 includes group identity, distribution-board + components and both one-to-one protection collections +- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON + transfers preserve the complete new relational state +- duplicate imports remap component UUIDs and both protection-owner references + together with the existing project structure +- versions 1 through 6 remain readable; version 6 maps the three established + section keys to group 1 and leaves new collections empty +- older flat circuit protection values remain present and are not guessed into + strict new protection configurations +- current snapshots reject invalid ownership, duplicate group numbers, + cross-entity BMKs and invalid protection configurations before persistence + Acceptance: - a current database migrates without data loss diff --git a/src/db/repositories/project-snapshot.repository.ts b/src/db/repositories/project-snapshot.repository.ts index c5051aa..7d645cc 100644 --- a/src/db/repositories/project-snapshot.repository.ts +++ b/src/db/repositories/project-snapshot.repository.ts @@ -9,6 +9,7 @@ import { legacyProjectStateSnapshotSchemaVersion, previousProjectStateSnapshotSchemaVersion, projectStateSnapshotSchemaVersion, + simultaneityFactorProjectStateSnapshotSchemaVersion, supplyTypesProjectStateSnapshotSchemaVersion, voltageProjectStateSnapshotSchemaVersion, } from "../../domain/models/project-state-snapshot.model.js"; @@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore { supplyTypesProjectStateSnapshotSchemaVersion && stored.schemaVersion !== voltageProjectStateSnapshotSchemaVersion && + stored.schemaVersion !== + simultaneityFactorProjectStateSnapshotSchemaVersion && stored.schemaVersion !== projectStateSnapshotSchemaVersion ) { throw new Error("Project snapshot schema version is not supported."); diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 1c27a97..2d42589 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -15,9 +15,12 @@ import type { import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { consumers } from "../schema/consumers.js"; +import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js"; @@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) { distributionBoards: byId(state.distributionBoards), circuitLists: byId(state.circuitLists), circuitSections: byId(state.circuitSections), + distributionBoardComponents: byId( + state.distributionBoardComponents + ), + circuitProtectionDevices: byKey( + state.circuitProtectionDevices, + "circuitId" + ), + distributionBoardComponentProtectionDevices: byKey( + state.distributionBoardComponentProtectionDevices, + "componentId" + ), circuits: byId( state.circuits.map((circuit) => ({ ...circuit, @@ -159,6 +173,15 @@ function byId(entries: readonly T[]) { ); } +function byKey< + TKey extends string, + TEntry extends Record, +>(entries: readonly TEntry[], key: TKey) { + return [...entries].sort((left, right) => + left[key].localeCompare(right[key]) + ); +} + function replaceProjectState( database: AppDatabase, state: ProjectStateSnapshot @@ -214,6 +237,21 @@ function replaceProjectState( isReserve: circuit.isReserve ? 1 : 0, })) ); + insertMany( + database, + distributionBoardComponents, + state.distributionBoardComponents + ); + insertMany( + database, + circuitProtectionDevices, + state.circuitProtectionDevices + ); + insertMany( + database, + distributionBoardComponentProtectionDevices, + state.distributionBoardComponentProtectionDevices + ); insertMany( database, circuitDeviceRows, diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index 9c6dbc6..ef20776 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -9,8 +9,11 @@ import { import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; +import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; @@ -69,6 +72,8 @@ export function readProjectStateSnapshot( displayName: circuitSections.displayName, prefix: circuitSections.prefix, sortOrder: circuitSections.sortOrder, + category: circuitSections.category, + groupNumber: circuitSections.groupNumber, }) .from(circuitSections) .innerJoin( @@ -82,6 +87,56 @@ export function readProjectStateSnapshot( asc(circuitSections.id) ) .all(); + const componentRows = database + .select({ component: distributionBoardComponents }) + .from(distributionBoardComponents) + .innerJoin( + circuitLists, + eq(circuitLists.id, distributionBoardComponents.circuitListId) + ) + .where(eq(circuitLists.projectId, projectId)) + .orderBy( + asc(distributionBoardComponents.circuitListId), + asc(distributionBoardComponents.placement), + asc(distributionBoardComponents.sortOrder), + asc(distributionBoardComponents.id) + ) + .all() + .map((row) => row.component); + const circuitProtectionRows = database + .select({ protection: circuitProtectionDevices }) + .from(circuitProtectionDevices) + .innerJoin( + circuits, + eq(circuits.id, circuitProtectionDevices.circuitId) + ) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .where(eq(circuitLists.projectId, projectId)) + .orderBy(asc(circuitProtectionDevices.circuitId)) + .all() + .map((row) => row.protection); + const componentProtectionRows = database + .select({ + protection: distributionBoardComponentProtectionDevices, + }) + .from(distributionBoardComponentProtectionDevices) + .innerJoin( + distributionBoardComponents, + eq( + distributionBoardComponents.id, + distributionBoardComponentProtectionDevices.componentId + ) + ) + .innerJoin( + circuitLists, + eq(circuitLists.id, distributionBoardComponents.circuitListId) + ) + .where(eq(circuitLists.projectId, projectId)) + .orderBy( + asc(distributionBoardComponentProtectionDevices.componentId) + ) + .all() + .map((row) => row.protection); const circuitRows = database .select({ id: circuits.id, @@ -190,6 +245,10 @@ export function readProjectStateSnapshot( distributionBoards: boardRows, circuitLists: listRows, circuitSections: sectionRows, + distributionBoardComponents: componentRows, + circuitProtectionDevices: circuitProtectionRows, + distributionBoardComponentProtectionDevices: + componentProtectionRows, circuits: circuitRows.map((circuit) => ({ ...circuit, isReserve: Boolean(circuit.isReserve), diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index a6361ff..6f927cc 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -10,8 +10,11 @@ import type { ProjectTransferStore } from "../../domain/ports/project-transfer.s import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; +import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; @@ -127,6 +130,21 @@ function insertProjectState( isReserve: circuit.isReserve ? 1 : 0, })) ); + insertMany( + database, + distributionBoardComponents, + state.distributionBoardComponents + ); + insertMany( + database, + circuitProtectionDevices, + state.circuitProtectionDevices + ); + insertMany( + database, + distributionBoardComponentProtectionDevices, + state.distributionBoardComponentProtectionDevices + ); insertMany( database, circuitDeviceRows, diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 3a6bef3..80eb6d1 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -3,6 +3,21 @@ import { defaultDistributionBoardSupplyTypes, distributionBoardSupplyTypes, } from "../../shared/constants/distribution-board.js"; +import { + circuitGroupCategories, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group.js"; +import { + distributionBoardComponentPlacements, + distributionBoardComponentRoles, +} from "../../shared/constants/distribution-board-component.js"; +import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; +import { + breakerTripCharacteristics, + fuseUtilizationCategories, + protectionDeviceTypes, + rcdTypes, +} from "../../shared/constants/protection-device.js"; import { resolveCircuitPhaseType, resolveProjectVoltage, @@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const; export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const; export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const; export const voltageProjectStateSnapshotSchemaVersion = 5 as const; -export const projectStateSnapshotSchemaVersion = 6 as const; +export const simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const; +export const projectStateSnapshotSchemaVersion = 7 as const; const idSchema = z.string().trim().min(1); const nullableStringSchema = z.string().nullable(); @@ -73,7 +89,7 @@ const circuitListSchema = z }) .strict(); -const circuitSectionSchema = z +const legacyCircuitSectionSchema = z .object({ id: idSchema, circuitListId: idSchema, @@ -84,6 +100,22 @@ const circuitSectionSchema = z }) .strict(); +const circuitSectionSchema = legacyCircuitSectionSchema + .extend({ + category: z.enum(circuitGroupCategories).nullable(), + groupNumber: z.number().int().positive().nullable(), + }) + .strict() + .superRefine((section, context) => { + if ((section.category === null) !== (section.groupNumber === null)) { + context.addIssue({ + code: "custom", + message: + "Circuit-section category and group number must both be set or both be null.", + }); + } + }); + const circuitDeviceRowSchema = z .object({ id: idSchema, @@ -173,21 +205,106 @@ const roomSchema = z }) .strict(); -const commonProjectStateSnapshotContents = { +const distributionBoardComponentSchema = z + .object({ + id: idSchema, + circuitListId: idSchema, + sectionId: idSchema.nullable(), + equipmentIdentifier: z.string().trim().min(1), + name: z.string().trim().min(1), + role: z.enum(distributionBoardComponentRoles), + placement: z.enum(distributionBoardComponentPlacements), + sortOrder: finiteNumberSchema, + }) + .strict(); + +const persistedProtectionDeviceFields = { + type: z.enum(protectionDeviceTypes), + ratedCurrentA: finiteNumberSchema.positive(), + fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(), + tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(), + rcdType: z.enum(rcdTypes).nullable(), + ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(), +}; + +function validatePersistedProtectionDevice( + value: { + type: (typeof protectionDeviceTypes)[number]; + ratedCurrentA: number; + fuseUtilizationCategory: + | (typeof fuseUtilizationCategories)[number] + | null; + tripCharacteristic: + | (typeof breakerTripCharacteristics)[number] + | null; + rcdType: (typeof rcdTypes)[number] | null; + ratedResidualCurrentMa: number | null; + }, + context: z.RefinementCtx +) { + const result = protectionDeviceConfigurationSchema.safeParse({ + type: value.type, + ratedCurrentA: value.ratedCurrentA, + ...(value.fuseUtilizationCategory === null + ? {} + : { fuseUtilizationCategory: value.fuseUtilizationCategory }), + ...(value.tripCharacteristic === null + ? {} + : { tripCharacteristic: value.tripCharacteristic }), + ...(value.rcdType === null ? {} : { rcdType: value.rcdType }), + ...(value.ratedResidualCurrentMa === null + ? {} + : { ratedResidualCurrentMa: value.ratedResidualCurrentMa }), + }); + if (!result.success) { + context.addIssue({ + code: "custom", + message: "Snapshot protection-device configuration is invalid.", + }); + } +} + +const circuitProtectionDeviceSchema = z + .object({ + circuitId: idSchema, + ...persistedProtectionDeviceFields, + }) + .strict() + .superRefine(validatePersistedProtectionDevice); + +const componentProtectionDeviceSchema = z + .object({ + componentId: idSchema, + ...persistedProtectionDeviceFields, + }) + .strict() + .superRefine(validatePersistedProtectionDevice); + +const legacyProjectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), - circuitSections: z.array(circuitSectionSchema), + circuitSections: z.array(legacyCircuitSectionSchema), circuits: z.array(circuitSchema), projectDevices: z.array(projectDeviceSchema), floors: z.array(floorSchema), rooms: z.array(roomSchema), }; +const currentProjectStateSnapshotContents = { + ...legacyProjectStateSnapshotContents, + circuitSections: z.array(circuitSectionSchema), + distributionBoardComponents: z.array(distributionBoardComponentSchema), + circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), + distributionBoardComponentProtectionDevices: z.array( + componentProtectionDeviceSchema + ), +}; + const legacyProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), project: legacyProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...commonProjectStateSnapshotContents, + ...legacyProjectStateSnapshotContents, }) .strict(); @@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), project: projectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...commonProjectStateSnapshotContents, + ...legacyProjectStateSnapshotContents, }) .strict(); @@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z ), project: projectSchema, distributionBoards: z.array(distributionBoardSchema), - ...commonProjectStateSnapshotContents, + ...legacyProjectStateSnapshotContents, }) .strict(); @@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion), project: currentProjectSchema, distributionBoards: z.array(distributionBoardSchema), - ...commonProjectStateSnapshotContents, + ...legacyProjectStateSnapshotContents, }) .strict(); @@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema = ), }); -export const projectStateSnapshotSchema = +const simultaneityFactorProjectStateSnapshotSchema = voltageProjectStateSnapshotSchema.extend({ - schemaVersion: z.literal(projectStateSnapshotSchemaVersion), + schemaVersion: z.literal( + simultaneityFactorProjectStateSnapshotSchemaVersion + ), distributionBoards: z.array(currentDistributionBoardSchema), }); +export const projectStateSnapshotSchema = + simultaneityFactorProjectStateSnapshotSchema.extend({ + schemaVersion: z.literal(projectStateSnapshotSchemaVersion), + ...currentProjectStateSnapshotContents, + }); + export type ProjectStateSnapshot = z.infer< typeof projectStateSnapshotSchema >; @@ -243,46 +368,65 @@ export function parseProjectStateSnapshot( const version = isPlainObject(value) ? value.schemaVersion : undefined; const parsedSnapshot = version === legacyProjectStateSnapshotSchemaVersion - ? upgradeVoltageProjectStateSnapshot( - upgradeSupplyTypesProjectStateSnapshot( - upgradeDistributionBoardProjectStateSnapshot( - upgradePreviousProjectStateSnapshot( - upgradeLegacyProjectStateSnapshot( - legacyProjectStateSnapshotSchema.parse(value) + ? upgradeSimultaneityFactorProjectStateSnapshot( + upgradeVoltageProjectStateSnapshot( + upgradeSupplyTypesProjectStateSnapshot( + upgradeDistributionBoardProjectStateSnapshot( + upgradePreviousProjectStateSnapshot( + upgradeLegacyProjectStateSnapshot( + legacyProjectStateSnapshotSchema.parse(value) + ) ) ) ) ) ) : version === previousProjectStateSnapshotSchemaVersion - ? upgradeVoltageProjectStateSnapshot( - upgradeSupplyTypesProjectStateSnapshot( - upgradeDistributionBoardProjectStateSnapshot( - upgradePreviousProjectStateSnapshot( - previousProjectStateSnapshotSchema.parse(value) + ? upgradeSimultaneityFactorProjectStateSnapshot( + upgradeVoltageProjectStateSnapshot( + upgradeSupplyTypesProjectStateSnapshot( + upgradeDistributionBoardProjectStateSnapshot( + upgradePreviousProjectStateSnapshot( + previousProjectStateSnapshotSchema.parse(value) + ) ) ) ) ) : version === distributionBoardProjectStateSnapshotSchemaVersion - ? upgradeVoltageProjectStateSnapshot( - upgradeSupplyTypesProjectStateSnapshot( - upgradeDistributionBoardProjectStateSnapshot( - distributionBoardProjectStateSnapshotSchema.parse(value) + ? upgradeSimultaneityFactorProjectStateSnapshot( + upgradeVoltageProjectStateSnapshot( + upgradeSupplyTypesProjectStateSnapshot( + upgradeDistributionBoardProjectStateSnapshot( + distributionBoardProjectStateSnapshotSchema.parse( + value + ) + ) ) ) ) : version === supplyTypesProjectStateSnapshotSchemaVersion - ? upgradeVoltageProjectStateSnapshot( - upgradeSupplyTypesProjectStateSnapshot( - supplyTypesProjectStateSnapshotSchema.parse(value) + ? upgradeSimultaneityFactorProjectStateSnapshot( + upgradeVoltageProjectStateSnapshot( + upgradeSupplyTypesProjectStateSnapshot( + supplyTypesProjectStateSnapshotSchema.parse(value) + ) ) ) : version === voltageProjectStateSnapshotSchemaVersion - ? upgradeVoltageProjectStateSnapshot( - voltageProjectStateSnapshotSchema.parse(value) + ? upgradeSimultaneityFactorProjectStateSnapshot( + upgradeVoltageProjectStateSnapshot( + voltageProjectStateSnapshotSchema.parse(value) + ) ) - : projectStateSnapshotSchema.parse(value); + : version === + simultaneityFactorProjectStateSnapshotSchemaVersion + ? upgradeSimultaneityFactorProjectStateSnapshot( + simultaneityFactorProjectStateSnapshotSchema.parse( + value + ) + ) + : projectStateSnapshotSchema.parse(value); const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot); assertProjectStateSnapshotRelations(snapshot); return snapshot; @@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot( function upgradeVoltageProjectStateSnapshot( snapshot: z.infer -): ProjectStateSnapshot { +): z.infer { return { ...snapshot, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion, distributionBoards: snapshot.distributionBoards.map((board) => ({ ...board, simultaneityFactor: 1, @@ -423,6 +567,39 @@ function upgradeVoltageProjectStateSnapshot( }; } +function upgradeSimultaneityFactorProjectStateSnapshot( + snapshot: z.infer +): ProjectStateSnapshot { + return { + ...snapshot, + schemaVersion: projectStateSnapshotSchemaVersion, + circuitSections: snapshot.circuitSections.map((section) => { + const category = initialCategoryBySectionKey(section.key); + return { + ...section, + category, + groupNumber: category === null ? null : 1, + }; + }), + distributionBoardComponents: [], + circuitProtectionDevices: [], + distributionBoardComponentProtectionDevices: [], + }; +} + +function initialCategoryBySectionKey( + key: string +): CircuitGroupCategory | null { + if ( + key === "lighting" || + key === "single_phase" || + key === "three_phase" + ) { + return key; + } + return null; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -461,10 +638,22 @@ function assertProjectStateSnapshotRelations( ); const floorIds = uniqueIds(snapshot.floors, "floor"); const roomIds = uniqueIds(snapshot.rooms, "room"); - uniqueIds(snapshot.circuits, "circuit"); + const circuitIds = uniqueIds(snapshot.circuits, "circuit"); + const componentIds = uniqueIds( + snapshot.distributionBoardComponents, + "distribution board component" + ); const sectionById = new Map( snapshot.circuitSections.map((entry) => [entry.id, entry]) ); + const componentById = new Map( + snapshot.distributionBoardComponents.map((entry) => [ + entry.id, + entry, + ]) + ); + const groupKeys = new Set(); + const equipmentIdentifiersByList = new Map>(); for (const board of snapshot.distributionBoards) { assertProjectOwnership(board.projectId, projectId, "distribution board"); @@ -500,6 +689,19 @@ function assertProjectStateSnapshotRelations( section.circuitListId, "circuit section list" ); + if (section.category !== null && section.groupNumber !== null) { + const groupKey = [ + section.circuitListId, + section.category, + section.groupNumber, + ].join(":"); + if (groupKeys.has(groupKey)) { + throw new Error( + "Snapshot contains a duplicate circuit group number." + ); + } + groupKeys.add(groupKey); + } } for (const device of snapshot.projectDevices) { assertProjectOwnership(device.projectId, projectId, "project device"); @@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations( "Snapshot circuit voltage must match project settings." ); } + registerEquipmentIdentifier( + equipmentIdentifiersByList, + circuit.circuitListId, + circuit.equipmentIdentifier + ); for (const row of circuit.deviceRows) { if (row.circuitId !== circuit.id) { throw new Error( @@ -575,6 +782,148 @@ function assertProjectStateSnapshotRelations( } } } + + const fixedComponentRolesByList = new Set(); + const groupComponentRolesBySection = new Set(); + for (const component of snapshot.distributionBoardComponents) { + assertReference( + circuitListIds, + component.circuitListId, + "distribution board component circuit list" + ); + if (component.sectionId !== null) { + assertReference( + sectionIds, + component.sectionId, + "distribution board component section" + ); + if ( + sectionById.get(component.sectionId)?.circuitListId !== + component.circuitListId + ) { + throw new Error( + "Snapshot distribution board component section belongs to a different circuit list." + ); + } + } + assertComponentPlacement(component); + registerEquipmentIdentifier( + equipmentIdentifiersByList, + component.circuitListId, + component.equipmentIdentifier + ); + if ( + component.role === "main_switch" || + component.role === "surge_protective_device" + ) { + assertUniqueKey( + fixedComponentRolesByList, + `${component.circuitListId}:${component.role}`, + "Snapshot contains duplicate fixed distribution board components." + ); + } + if ( + component.role === "group_upstream_protection" || + component.role === "group_residual_current_protection" + ) { + assertUniqueKey( + groupComponentRolesBySection, + `${component.sectionId}:${component.role}`, + "Snapshot contains duplicate group protection components." + ); + } + } + + const protectedCircuitIds = new Set(); + for (const protection of snapshot.circuitProtectionDevices) { + assertReference( + circuitIds, + protection.circuitId, + "circuit protection device circuit" + ); + assertUniqueKey( + protectedCircuitIds, + protection.circuitId, + "Snapshot contains duplicate circuit protection devices." + ); + } + + const protectedComponentIds = new Set(); + for (const protection of snapshot.distributionBoardComponentProtectionDevices) { + assertReference( + componentIds, + protection.componentId, + "component protection device component" + ); + assertUniqueKey( + protectedComponentIds, + protection.componentId, + "Snapshot contains duplicate component protection devices." + ); + const component = componentById.get(protection.componentId)!; + if ( + component.role !== "group_upstream_protection" && + component.role !== "group_residual_current_protection" + ) { + throw new Error( + "Snapshot protection data belongs to a non-protection component." + ); + } + } +} + +function assertComponentPlacement( + component: ProjectStateSnapshot["distributionBoardComponents"][number] +) { + const isFixedHeader = + component.role === "main_switch" || + component.role === "surge_protective_device"; + const isGroupProtection = + component.role === "group_upstream_protection" || + component.role === "group_residual_current_protection"; + if ( + (isFixedHeader && + (component.placement !== "header" || + component.sectionId !== null)) || + (isGroupProtection && + (component.placement !== "group" || + component.sectionId === null)) || + (component.role === "auxiliary" && + (component.placement !== "footer" || + component.sectionId !== null)) + ) { + throw new Error( + "Snapshot distribution board component placement is invalid." + ); + } +} + +function registerEquipmentIdentifier( + identifiersByList: Map>, + circuitListId: string, + equipmentIdentifier: string +) { + const identifiers = + identifiersByList.get(circuitListId) ?? new Set(); + const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de"); + if (identifiers.has(normalized)) { + throw new Error( + "Snapshot contains duplicate equipment identifiers in one circuit list." + ); + } + identifiers.add(normalized); + identifiersByList.set(circuitListId, identifiers); +} + +function assertUniqueKey( + keys: Set, + key: string, + message: string +) { + if (keys.has(key)) { + throw new Error(message); + } + keys.add(key); } function uniqueIds( diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index baa20a0..c6de1f7 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -79,6 +79,10 @@ export function remapProjectState( const listIds = idMap(source.circuitLists, createId); const sectionIds = idMap(source.circuitSections, createId); const circuitIds = idMap(source.circuits, createId); + const componentIds = idMap( + source.distributionBoardComponents, + createId + ); const deviceIds = idMap(source.projectDevices, createId); const floorIds = idMap(source.floors, createId); const roomIds = idMap(source.rooms, createId); @@ -110,6 +114,35 @@ export function remapProjectState( id: requiredId(sectionIds, section.id), circuitListId: requiredId(listIds, section.circuitListId), })), + distributionBoardComponents: + source.distributionBoardComponents.map((component) => ({ + ...component, + id: requiredId(componentIds, component.id), + circuitListId: requiredId( + listIds, + component.circuitListId + ), + sectionId: + component.sectionId === null + ? null + : requiredId(sectionIds, component.sectionId), + })), + circuitProtectionDevices: source.circuitProtectionDevices.map( + (protection) => ({ + ...protection, + circuitId: requiredId(circuitIds, protection.circuitId), + }) + ), + distributionBoardComponentProtectionDevices: + source.distributionBoardComponentProtectionDevices.map( + (protection) => ({ + ...protection, + componentId: requiredId( + componentIds, + protection.componentId + ), + }) + ), circuits: source.circuits.map((circuit) => ({ ...circuit, id: requiredId(circuitIds, circuit.id), diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index cd181a3..b59998f 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -14,8 +14,11 @@ import { ProjectTransferRepository } from "../src/db/repositories/project-transf import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { floors } from "../src/db/schema/floors.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; @@ -129,9 +132,58 @@ function createTestDatabase(): DatabaseContext { cosPhi: 0.9, }) .run(); + context.db + .insert(circuitProtectionDevices) + .values({ + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 10, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, + }) + .run(); + context.db + .insert(distributionBoardComponents) + .values({ + id: "component-1", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }) + .run(); + context.db + .insert(distributionBoardComponentProtectionDevices) + .values({ + componentId: "component-1", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }) + .run(); return context; } +function removeVersionSevenFields(state: Record) { + delete state.distributionBoardComponents; + delete state.circuitProtectionDevices; + delete state.distributionBoardComponentProtectionDevices; + for (const section of state.circuitSections as Array< + Record + >) { + delete section.category; + delete section.groupNumber; + } +} + describe("project snapshot repository", () => { it("captures, hashes and lists a complete named logical snapshot", () => { const context = createTestDatabase(); @@ -190,6 +242,12 @@ describe("project snapshot repository", () => { context.db.select().from(circuitSections).all().length ); assert.equal(state.circuits.length, 1); + assert.equal(state.circuitProtectionDevices.length, 1); + assert.equal(state.distributionBoardComponents.length, 1); + assert.equal( + state.distributionBoardComponentProtectionDevices.length, + 1 + ); assert.equal(state.circuits[0].deviceRows.length, 1); assert.equal( state.circuits[0].deviceRows[0].linkedProjectDeviceId, @@ -291,6 +349,7 @@ describe("project snapshot repository", () => { project: Record; }; currentPayload.schemaVersion = 1; + removeVersionSevenFields(currentPayload); delete currentPayload.project.internalProjectNumber; delete currentPayload.project.externalProjectNumber; delete currentPayload.project.buildingOwner; @@ -368,6 +427,19 @@ describe("portable project transfer", () => { copied.state.circuits[0].id, source.state.circuits[0].id ); + assert.notEqual( + copied.state.distributionBoardComponents[0].id, + source.state.distributionBoardComponents[0].id + ); + assert.equal( + copied.state.circuitProtectionDevices[0].circuitId, + copied.state.circuits[0].id + ); + assert.equal( + copied.state.distributionBoardComponentProtectionDevices[0] + .componentId, + copied.state.distributionBoardComponents[0].id + ); assert.equal( copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId, copied.state.projectDevices[0].id @@ -438,6 +510,7 @@ describe("portable project transfer", () => { distributionBoards: Array>; }; legacyState.schemaVersion = 2; + removeVersionSevenFields(legacyState); delete ( legacyState.project as Record ).enabledDistributionBoardSupplyTypes; diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index b890856..73b2591 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -27,9 +27,12 @@ import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/pro import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { consumers } from "../src/db/schema/consumers.js"; +import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { floors } from "../src/db/schema/floors.js"; import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js"; @@ -132,6 +135,43 @@ function createTestDatabase(): DatabaseContext { simultaneityFactor: 0.8, }) .run(); + context.db + .insert(circuitProtectionDevices) + .values({ + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 10, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, + }) + .run(); + context.db + .insert(distributionBoardComponents) + .values({ + id: "component-1", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }) + .run(); + context.db + .insert(distributionBoardComponentProtectionDevices) + .values({ + componentId: "component-1", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }) + .run(); return context; } @@ -173,6 +213,16 @@ function changeCompleteProjectState(context: DatabaseContext) { }) .where(eq(circuits.id, "circuit-1")) .run(); + context.db + .update(circuitProtectionDevices) + .set({ ratedCurrentA: 16 }) + .where(eq(circuitProtectionDevices.circuitId, "circuit-1")) + .run(); + context.db + .update(distributionBoardComponents) + .set({ name: "Geänderter Gruppen-FI" }) + .where(eq(distributionBoardComponents.id, "component-1")) + .run(); context.db .insert(floors) .values({ diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 2f89654..66aeb72 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -32,6 +32,9 @@ function minimalSnapshot() { distributionBoards: [], circuitLists: [], circuitSections: [], + distributionBoardComponents: [], + circuitProtectionDevices: [], + distributionBoardComponentProtectionDevices: [], circuits: [], projectDevices: [], floors: [], @@ -39,6 +42,111 @@ function minimalSnapshot() { }; } +function legacyMinimalSnapshot() { + const { + distributionBoardComponents: _distributionBoardComponents, + circuitProtectionDevices: _circuitProtectionDevices, + distributionBoardComponentProtectionDevices: + _distributionBoardComponentProtectionDevices, + ...snapshot + } = minimalSnapshot(); + return snapshot; +} + +function protectedSnapshot() { + return { + ...minimalSnapshot(), + distributionBoards: [ + { + id: "board-1", + projectId: "project-1", + name: "UV 1", + floorId: null, + supplyType: null, + simultaneityFactor: 1, + }, + ], + 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, + protectionType: null, + protectionRatedCurrent: null, + protectionCharacteristic: null, + 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 a versioned logical project state", () => { const snapshot = parseProjectStateSnapshot(minimalSnapshot()); @@ -52,7 +160,7 @@ describe("project state snapshot model", () => { it("upgrades version-one snapshots with empty project metadata", () => { const legacy = { - ...minimalSnapshot(), + ...legacyMinimalSnapshot(), schemaVersion: 1 as const, project: { id: "project-1", @@ -82,7 +190,7 @@ describe("project state snapshot model", () => { it("upgrades version-two distribution boards without inferred assignments", () => { const previous = { - ...minimalSnapshot(), + ...legacyMinimalSnapshot(), schemaVersion: 2 as const, project: { ...minimalSnapshot().project, @@ -110,7 +218,7 @@ describe("project state snapshot model", () => { it("upgrades version-three snapshots with all project supply types", () => { const previous = { - ...minimalSnapshot(), + ...legacyMinimalSnapshot(), schemaVersion: 3 as const, project: { ...minimalSnapshot().project, @@ -131,7 +239,7 @@ describe("project state snapshot model", () => { it("normalizes version-four device voltages to project settings", () => { const previous = { - ...minimalSnapshot(), + ...legacyMinimalSnapshot(), schemaVersion: 4 as const, projectDevices: [ { @@ -161,7 +269,7 @@ describe("project state snapshot model", () => { it("upgrades version-five distribution boards with a neutral factor", () => { const previous = { - ...minimalSnapshot(), + ...legacyMinimalSnapshot(), schemaVersion: 5 as const, distributionBoards: [ { @@ -183,6 +291,116 @@ describe("project state snapshot model", () => { ); }); + it("upgrades version-six sections without inventing component data", () => { + const previous = { + ...legacyMinimalSnapshot(), + schemaVersion: 6 as const, + circuitSections: [ + { + id: "section-lighting", + circuitListId: "list-1", + key: "lighting", + displayName: "Beleuchtung", + prefix: "-1F", + sortOrder: 10, + }, + { + id: "section-unassigned", + circuitListId: "list-1", + key: "unassigned", + displayName: "Nicht zugeordnet", + prefix: "-UF", + sortOrder: 90, + }, + ], + distributionBoards: [ + { + id: "board-1", + projectId: "project-1", + name: "UV 1", + floorId: null, + supplyType: null, + simultaneityFactor: 1, + }, + ], + circuitLists: [ + { + id: "list-1", + projectId: "project-1", + distributionBoardId: "board-1", + name: "UV 1 Stromkreisliste", + }, + ], + }; + + const snapshot = parseProjectStateSnapshot(previous); + + assert.deepEqual( + snapshot.circuitSections.map( + ({ key, category, groupNumber }) => ({ + key, + category, + groupNumber, + }) + ), + [ + { + key: "lighting", + category: "lighting", + groupNumber: 1, + }, + { + key: "unassigned", + category: null, + groupNumber: null, + }, + ] + ); + assert.deepEqual(snapshot.distributionBoardComponents, []); + assert.deepEqual(snapshot.circuitProtectionDevices, []); + assert.deepEqual( + snapshot.distributionBoardComponentProtectionDevices, + [] + ); + }); + + it("validates the complete group, component and protection graph", () => { + const snapshot = parseProjectStateSnapshot(protectedSnapshot()); + + assert.equal(snapshot.circuitSections[0]?.groupNumber, 1); + assert.equal( + snapshot.distributionBoardComponents[0]?.sectionId, + "section-1" + ); + 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 cross-project ownership", () => { assert.throws( () =>