diff --git a/AGENTS.md b/AGENTS.md index c25de10..4db7767 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,12 @@ inverse removes only the same unchanged and still-empty structure; the POST route requires `expectedRevision` and returns the updated history state. Stored schema-version 1 and 2 setup commands remain executable with their four legacy sections and without invented components. +Mutable group-protection and auxiliary distribution-board components use +`distribution-board-component.insert` and +`distribution-board-component.delete`. Their complete snapshots include the +optional one-to-one protection-device state, and deletion rejects stale data. +Fixed main-switch and surge-protection header roles are excluded from these +general component commands. Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index bb30469..2fd1b07 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -285,6 +285,16 @@ nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die Freigabe der Netzart in den Projekteinstellungen. Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar. +`distribution-board-component.insert` und +`distribution-board-component.delete` versionieren die Anlage und Entfernung +veränderlicher Gruppen-Schutzgeräte und zusätzlicher Verteilergeräte. Der +vollständige Snapshot enthält die stabile Komponenten-UUID und, für +Gruppen-Schutzgeräte, die getrennte 1:1-Schutzgerätekonfiguration. Eigentum an +Projekt, Stromkreisliste und Gruppe sowie der unveränderte Löschzustand werden +innerhalb derselben Transaktion geprüft. Die festen Kopfkomponenten +Hauptschalter und Überspannungsableiter sind von diesen allgemeinen Commands +ausgeschlossen. API- und Editorintegration folgen in den nächsten +Arbeitspaketen. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und @@ -355,8 +365,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Ein triggergeführtes Register erzwingt bereits eine normalisierte, stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden - Snapshot-Schema 7; Command- und UI-Integration folgen in den nächsten - abgegrenzten Arbeitspaketen. + Snapshot-Schema 7. Persistente Insert/Delete-Commands für veränderliche + Verteilerkomponenten sind integriert; weitere Command- und UI-Schritte + folgen in 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 7a5fb03..9ff1544 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -20,7 +20,10 @@ requirements and intended sequencing, not proof of implementation. mapping. - [x] Phase C1: versioned new-board command with three groups and fixed header components. -- [ ] Phase C2: component and group CRUD/reorder commands. +- [x] Phase C2a1: persistent insert/delete commands for mutable group + protection and auxiliary components. +- [ ] Phase C2a2: component update and reorder commands. +- [ ] Phase C2b: group CRUD and reorder commands. - [ ] Phase D: group numbering, moves and destructive operations. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. 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 ad04919..c43d251 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -593,8 +593,9 @@ Acceptance: ### C. Persistent Commands and Board Defaults -Status: In progress. New-board defaults C1 are complete; component and group -management commands C2 are pending. +Status: In progress. New-board defaults C1 and component insert/delete commands +C2a1 are complete. Component update/reorder and group management remain +pending. - extend `distribution-board.insert` with fixed components and three groups - implement component and group CRUD commands @@ -612,6 +613,19 @@ Implemented in C1: - stored schema versions 1 and 2 retain four legacy sections and receive no invented components +Implemented in C2a1: + +- `distribution-board-component.insert` and + `distribution-board-component.delete` persist complete component snapshots + with their optional one-to-one protection-device configuration +- mutable group protection components validate their group ownership and + protection-device combination before writing +- auxiliary components remain footer entries without protection-device data +- fixed header roles are excluded from these general CRUD commands +- deletion requires an exact unchanged snapshot; Undo/Redo restores the same + component UUID and a late revision/history failure rolls back the complete + write + Acceptance: - new boards contain the agreed fixed structure diff --git a/src/db/repositories/distribution-board-component-structure-project-command.repository.ts b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts new file mode 100644 index 0000000..5f9117c --- /dev/null +++ b/src/db/repositories/distribution-board-component-structure-project-command.repository.ts @@ -0,0 +1,193 @@ +import { and, eq } from "drizzle-orm"; +import { + assertDistributionBoardComponentDeleteProjectCommand, + assertDistributionBoardComponentInsertProjectCommand, + createDistributionBoardComponentDeleteProjectCommand, + createDistributionBoardComponentInsertProjectCommand, + distributionBoardComponentDeleteCommandType, + distributionBoardComponentInsertCommandType, + type DistributionBoardComponentSnapshot, + type DistributionBoardComponentStructureProjectCommand, +} from "../../domain/models/distribution-board-component-structure-project-command.model.js"; +import type { + DistributionBoardComponentStructureProjectCommandStore, + ExecuteDistributionBoardComponentStructureCommandInput, +} from "../../domain/ports/distribution-board-component-structure-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitSections } from "../schema/circuit-sections.js"; +import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class DistributionBoardComponentStructureProjectCommandRepository + implements DistributionBoardComponentStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute( + input: ExecuteDistributionBoardComponentStructureCommandInput + ) { + return executeProjectCommandTransaction( + this.database, + input, + (tx) => + this.applyCommand( + tx, + input.projectId, + input.command + ) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: DistributionBoardComponentStructureProjectCommand + ): DistributionBoardComponentStructureProjectCommand { + if (command.type === distributionBoardComponentInsertCommandType) { + assertDistributionBoardComponentInsertProjectCommand(command); + this.insert(database, projectId, command.payload.snapshot); + return createDistributionBoardComponentDeleteProjectCommand( + command.payload.snapshot + ); + } + if (command.type === distributionBoardComponentDeleteCommandType) { + assertDistributionBoardComponentDeleteProjectCommand(command); + this.delete(database, projectId, command.payload.snapshot); + return createDistributionBoardComponentInsertProjectCommand( + command.payload.snapshot + ); + } + throw new Error( + "Unsupported distribution-board component structure command." + ); + } + + private insert( + database: AppDatabase, + projectId: string, + snapshot: DistributionBoardComponentSnapshot + ) { + this.assertOwnership(database, projectId, snapshot); + const existing = database + .select({ id: distributionBoardComponents.id }) + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, snapshot.component.id)) + .get(); + if (existing) { + throw new Error("Distribution-board component id already exists."); + } + database + .insert(distributionBoardComponents) + .values(snapshot.component) + .run(); + if (snapshot.protectionDevice !== null) { + database + .insert(distributionBoardComponentProtectionDevices) + .values(snapshot.protectionDevice) + .run(); + } + } + + private delete( + database: AppDatabase, + projectId: string, + snapshot: DistributionBoardComponentSnapshot + ) { + this.assertOwnership(database, projectId, snapshot); + const component = database + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, snapshot.component.id)) + .get(); + const protection = database + .select() + .from(distributionBoardComponentProtectionDevices) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + snapshot.component.id + ) + ) + .get(); + if ( + !component || + !sameRecord(snapshot.component, component) || + !sameNullableRecord(snapshot.protectionDevice, protection) + ) { + throw new Error( + "Distribution-board component changed before deletion." + ); + } + const deleted = database + .delete(distributionBoardComponents) + .where( + and( + eq(distributionBoardComponents.id, component.id), + eq( + distributionBoardComponents.circuitListId, + snapshot.component.circuitListId + ) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error( + "Distribution-board component could not be deleted." + ); + } + } + + private assertOwnership( + database: AppDatabase, + projectId: string, + snapshot: DistributionBoardComponentSnapshot + ) { + const list = database + .select({ projectId: circuitLists.projectId }) + .from(circuitLists) + .where(eq(circuitLists.id, snapshot.component.circuitListId)) + .get(); + if (!list || list.projectId !== projectId) { + throw new Error( + "Distribution-board component circuit list does not belong to project." + ); + } + if (snapshot.component.sectionId !== null) { + const section = database + .select({ circuitListId: circuitSections.circuitListId }) + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.component.sectionId)) + .get(); + if ( + !section || + section.circuitListId !== snapshot.component.circuitListId + ) { + throw new Error( + "Distribution-board component section does not belong to circuit list." + ); + } + } + } +} + +function sameNullableRecord( + expected: object | null, + actual: object | undefined +) { + return expected === null + ? actual === undefined + : actual !== undefined && sameRecord(expected, actual); +} + +function sameRecord(expected: object, actual: object) { + const expectedEntries = Object.entries(expected); + const actualRecord = actual as Record; + return ( + expectedEntries.length === Object.keys(actual).length && + expectedEntries.every( + ([key, value]) => actualRecord[key] === value + ) + ); +} diff --git a/src/domain/models/distribution-board-component-structure-project-command.model.ts b/src/domain/models/distribution-board-component-structure-project-command.model.ts new file mode 100644 index 0000000..9f9a187 --- /dev/null +++ b/src/domain/models/distribution-board-component-structure-project-command.model.ts @@ -0,0 +1,254 @@ +import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js"; +import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js"; +import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; +import type { + BreakerTripCharacteristic, + FuseUtilizationCategory, + ProtectionDeviceType, + RcdType, +} from "../../shared/constants/protection-device.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const distributionBoardComponentInsertCommandType = + "distribution-board-component.insert" as const; +export const distributionBoardComponentDeleteCommandType = + "distribution-board-component.delete" as const; +export const distributionBoardComponentStructureCommandSchemaVersion = + 1 as const; + +type MutableDistributionBoardComponentRole = + | "group_upstream_protection" + | "group_residual_current_protection" + | "auxiliary"; + +export interface DistributionBoardComponentSnapshot { + component: { + id: string; + circuitListId: string; + sectionId: string | null; + equipmentIdentifier: string; + name: string; + role: MutableDistributionBoardComponentRole; + placement: DistributionBoardComponentPlacement; + sortOrder: number; + }; + protectionDevice: { + componentId: string; + type: ProtectionDeviceType; + ratedCurrentA: number; + fuseUtilizationCategory: FuseUtilizationCategory | null; + tripCharacteristic: BreakerTripCharacteristic | null; + rcdType: RcdType | null; + ratedResidualCurrentMa: number | null; + } | null; +} + +interface DistributionBoardComponentStructurePayload { + snapshot: DistributionBoardComponentSnapshot; +} + +export interface DistributionBoardComponentInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion; + type: typeof distributionBoardComponentInsertCommandType; +} + +export interface DistributionBoardComponentDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion; + type: typeof distributionBoardComponentDeleteCommandType; +} + +export type DistributionBoardComponentStructureProjectCommand = + | DistributionBoardComponentInsertProjectCommand + | DistributionBoardComponentDeleteProjectCommand; + +export function createDistributionBoardComponentInsertProjectCommand( + snapshot: DistributionBoardComponentSnapshot +): DistributionBoardComponentInsertProjectCommand { + const command: DistributionBoardComponentInsertProjectCommand = { + schemaVersion: + distributionBoardComponentStructureCommandSchemaVersion, + type: distributionBoardComponentInsertCommandType, + payload: { snapshot }, + }; + assertDistributionBoardComponentInsertProjectCommand(command); + return command; +} + +export function createDistributionBoardComponentDeleteProjectCommand( + snapshot: DistributionBoardComponentSnapshot +): DistributionBoardComponentDeleteProjectCommand { + const command: DistributionBoardComponentDeleteProjectCommand = { + schemaVersion: + distributionBoardComponentStructureCommandSchemaVersion, + type: distributionBoardComponentDeleteCommandType, + payload: { snapshot }, + }; + assertDistributionBoardComponentDeleteProjectCommand(command); + return command; +} + +export function assertDistributionBoardComponentInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardComponentInsertProjectCommand { + assertStructureCommand( + command, + distributionBoardComponentInsertCommandType + ); +} + +export function assertDistributionBoardComponentDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is DistributionBoardComponentDeleteProjectCommand { + assertStructureCommand( + command, + distributionBoardComponentDeleteCommandType + ); +} + +function assertStructureCommand( + command: SerializedProjectCommand, + type: + | typeof distributionBoardComponentInsertCommandType + | typeof distributionBoardComponentDeleteCommandType +) { + if ( + command.schemaVersion !== + distributionBoardComponentStructureCommandSchemaVersion || + command.type !== type || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error( + "Unsupported distribution-board component structure command." + ); + } + assertDistributionBoardComponentSnapshot(command.payload.snapshot); +} + +export function assertDistributionBoardComponentSnapshot( + value: unknown +): asserts value is DistributionBoardComponentSnapshot { + if ( + !isPlainObject(value) || + Object.keys(value).length !== 2 || + !isPlainObject(value.component) + ) { + throw new Error( + "Distribution-board component snapshot is invalid." + ); + } + const component = value.component; + if (Object.keys(component).length !== 8) { + throw new Error( + "Distribution-board component snapshot is incomplete." + ); + } + for (const field of [ + "id", + "circuitListId", + "equipmentIdentifier", + "name", + ] as const) { + assertNonEmptyString(component[field], `component.${field}`); + } + if ( + component.role !== "group_upstream_protection" && + component.role !== "group_residual_current_protection" && + component.role !== "auxiliary" + ) { + throw new Error( + "Fixed distribution-board components cannot use component CRUD commands." + ); + } + if ( + !distributionBoardComponentPlacements.includes( + component.placement as DistributionBoardComponentPlacement + ) || + !Number.isFinite(component.sortOrder) + ) { + throw new Error( + "Distribution-board component placement or sort order is invalid." + ); + } + const groupComponent = component.role !== "auxiliary"; + if ( + groupComponent !== + (component.placement === "group" && + typeof component.sectionId === "string" && + Boolean(component.sectionId.trim())) + ) { + throw new Error( + "Group protection components require group placement and a section." + ); + } + if ( + !groupComponent && + (component.placement !== "footer" || + component.sectionId !== null) + ) { + throw new Error( + "Auxiliary components require footer placement without a section." + ); + } + + if (groupComponent) { + if (!isPlainObject(value.protectionDevice)) { + throw new Error( + "Group protection components require protection data." + ); + } + assertProtectionDevice( + value.protectionDevice, + component.id as string + ); + } else if (value.protectionDevice !== null) { + throw new Error( + "Auxiliary components cannot contain protection data." + ); + } +} + +function assertProtectionDevice( + value: Record, + componentId: string +) { + if ( + Object.keys(value).length !== 7 || + value.componentId !== componentId + ) { + throw new Error( + "Distribution-board component protection snapshot is invalid." + ); + } + 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) { + throw new Error( + "Distribution-board component protection configuration is invalid." + ); + } +} + +function assertNonEmptyString(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/distribution-board-component-structure-project-command.store.ts b/src/domain/ports/distribution-board-component-structure-project-command.store.ts new file mode 100644 index 0000000..85647ba --- /dev/null +++ b/src/domain/ports/distribution-board-component-structure-project-command.store.ts @@ -0,0 +1,24 @@ +import type { DistributionBoardComponentStructureProjectCommand } from "../models/distribution-board-component-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteDistributionBoardComponentStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: DistributionBoardComponentStructureProjectCommand; +} + +export interface DistributionBoardComponentStructureProjectCommandStore { + execute( + input: ExecuteDistributionBoardComponentStructureCommandInput + ): { + revision: AppendedProjectRevision; + inverse: DistributionBoardComponentStructureProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 2d5f8b9..c8c7195 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -41,6 +41,12 @@ import { assertDistributionBoardUpdateProjectCommand, distributionBoardUpdateCommandType, } from "../models/distribution-board-project-command.model.js"; +import { + assertDistributionBoardComponentDeleteProjectCommand, + assertDistributionBoardComponentInsertProjectCommand, + distributionBoardComponentDeleteCommandType, + distributionBoardComponentInsertCommandType, +} from "../models/distribution-board-component-structure-project-command.model.js"; import { assertDistributionBoardDeleteProjectCommand, assertDistributionBoardInsertProjectCommand, @@ -91,6 +97,7 @@ import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit- import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js"; import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js"; +import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, ExecutedProjectCommand, @@ -135,6 +142,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore, private readonly projectSettingsStore: ProjectSettingsProjectCommandStore, private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore, + private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -296,6 +304,24 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case distributionBoardComponentInsertCommandType: { + assertDistributionBoardComponentInsertProjectCommand( + input.command + ); + return this.distributionBoardComponentStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case distributionBoardComponentDeleteCommandType: { + assertDistributionBoardComponentDeleteProjectCommand( + input.command + ); + return this.distributionBoardComponentStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 60b798d..6fce755 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -33,6 +33,8 @@ const commandTypeLabels: Record = { "distribution-board.insert": "Verteilung angelegt", "distribution-board.update": "Verteilung bearbeitet", "distribution-board.delete": "Verteilung entfernt", + "distribution-board-component.insert": "Verteilergerät angelegt", + "distribution-board-component.delete": "Verteilergerät entfernt", "project-floor.insert": "Geschoss angelegt", "project-floor.delete": "Geschoss entfernt", "project-room.insert": "Raum angelegt", diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 6b506b3..a02893d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js"; @@ -27,6 +28,8 @@ export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); +export const distributionBoardComponentStructureProjectCommandStore = + new DistributionBoardComponentStructureProjectCommandRepository(db); export const projectLocationStructureProjectCommandStore = new ProjectLocationStructureProjectCommandRepository(db); export const circuitSectionReorderProjectCommandStore = @@ -59,5 +62,6 @@ export const projectCommandService = new ProjectCommandService( projectDeviceRowSyncProjectCommandStore, projectSettingsProjectCommandStore, projectStateRestoreCommandStore, + distributionBoardComponentStructureProjectCommandStore, projectHistoryStore ); diff --git a/tests/distribution-board-component-structure-project-command.repository.test.ts b/tests/distribution-board-component-structure-project-command.repository.test.ts new file mode 100644 index 0000000..657a783 --- /dev/null +++ b/tests/distribution-board-component-structure-project-command.repository.test.ts @@ -0,0 +1,433 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; +import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createDistributionBoardComponentInsertProjectCommand, + type DistributionBoardComponentSnapshot, +} from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; +import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; +import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Projekt" }, + { id: "project-2", name: "Fremdprojekt" }, + ]) + .run(); + new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + return context; +} + +function getListAndSection(context: DatabaseContext) { + const list = context.db.select().from(circuitLists).get(); + assert.ok(list); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, list.id)) + .get(); + assert.ok(section); + return { list, section }; +} + +describe("distribution-board component structure project command", () => { + it("inserts an auxiliary component and preserves it through undo and redo", () => { + const context = createTestDatabase(); + try { + const { list } = getListAndSection(context); + const snapshot: DistributionBoardComponentSnapshot = { + component: { + id: "component-aux", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-K1", + name: "KNX Schaltaktor", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }, + protectionDevice: null, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + const command = + createDistributionBoardComponentInsertProjectCommand(snapshot); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, "component-aux")) + .get(), + snapshot.component + ); + + const undo = history.getNextCommand("project-1", "undo"); + assert.ok(undo); + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: undo.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, "component-aux")) + .get(), + undefined + ); + + const redo = history.getNextCommand("project-1", "redo"); + assert.ok(redo); + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: redo.changeSetId, + command, + }); + assert.ok( + context.db + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.id, "component-aux")) + .get() + ); + } finally { + context.close(); + } + }); + + it("stores and restores a group RCD with its protection data atomically", () => { + const context = createTestDatabase(); + try { + const { list, section } = getListAndSection(context); + const snapshot: DistributionBoardComponentSnapshot = { + component: { + id: "component-rcd", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }, + protectionDevice: { + componentId: "component-rcd", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + snapshot + ), + }); + assert.deepEqual( + context.db + .select() + .from(distributionBoardComponentProtectionDevices) + .get(), + snapshot.protectionDevice + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db + .select() + .from(distributionBoardComponentProtectionDevices) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + + it("rejects fixed roles, foreign sections, duplicate BMKs and stale revisions", () => { + const context = createTestDatabase(); + try { + const { list } = getListAndSection(context); + assert.throws( + () => + createDistributionBoardComponentInsertProjectCommand({ + component: { + id: "fixed", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-Q1", + name: "Hauptschalter", + role: "main_switch", + placement: "header", + sortOrder: 10, + } as never, + protectionDevice: null, + }), + /Fixed distribution-board components/ + ); + const snapshot: DistributionBoardComponentSnapshot = { + component: { + id: "duplicate", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: " -q0 ", + name: "Doppelt", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }, + protectionDevice: null, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + const foreignBoard = new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections( + "project-2", + "UV fremd" + ); + const foreignSection = context.db + .select() + .from(circuitSections) + .where( + eq(circuitSections.circuitListId, foreignBoard.id) + ) + .get(); + assert.ok(foreignSection); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand({ + component: { + id: "foreign-section", + circuitListId: list.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1Q2.0", + name: "Fremder Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }, + protectionDevice: { + componentId: "foreign-section", + type: "FI", + ratedCurrentA: 40, + fuseUtilizationCategory: null, + tripCharacteristic: null, + rcdType: "A", + ratedResidualCurrentMa: 30, + }, + }), + }), + /section does not belong/ + ); + const staleSnapshot: DistributionBoardComponentSnapshot = { + component: { + ...snapshot.component, + id: "stale", + equipmentIdentifier: "-K-stale", + }, + protectionDevice: null, + }; + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + staleSnapshot + ), + }), + ProjectRevisionConflictError + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + snapshot + ), + }), + /UNIQUE constraint failed/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rejects changed deletes and rolls back late history failures", () => { + const context = createTestDatabase(); + try { + const { list } = getListAndSection(context); + const snapshot: DistributionBoardComponentSnapshot = { + component: { + id: "component-change", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-K2", + name: "Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 20, + }, + protectionDevice: null, + }; + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand( + snapshot + ), + }); + context.db + .update(distributionBoardComponents) + .set({ name: "Direkt geändert" }) + .where(eq(distributionBoardComponents.id, snapshot.component.id)) + .run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: inserted.inverse, + }), + /changed before deletion/ + ); + } finally { + context.close(); + } + + const rollback = createTestDatabase(); + try { + const { list } = getListAndSection(rollback); + rollback.sqlite.exec(` + CREATE TRIGGER fail_component_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced component history failure'); + END; + `); + const repository = + new DistributionBoardComponentStructureProjectCommandRepository( + rollback.db + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createDistributionBoardComponentInsertProjectCommand({ + component: { + id: "component-rollback", + circuitListId: list.id, + sectionId: null, + equipmentIdentifier: "-K3", + name: "Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 30, + }, + protectionDevice: null, + }), + }), + /forced component history failure/ + ); + assert.equal( + rollback.db + .select() + .from(distributionBoardComponents) + .where( + eq( + distributionBoardComponents.id, + "component-rollback" + ) + ) + .get(), + undefined + ); + } finally { + rollback.close(); + } + }); +}); diff --git a/tests/distribution-board-component.test.ts b/tests/distribution-board-component.test.ts index ba4ac10..5652e9c 100644 --- a/tests/distribution-board-component.test.ts +++ b/tests/distribution-board-component.test.ts @@ -7,6 +7,7 @@ import { distributionBoardComponentRoles, } from "../src/shared/constants/distribution-board-component.js"; import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js"; +import "./distribution-board-component-structure-project-command.repository.test.js"; describe("distribution board component catalog", () => { it("defines the agreed component roles and placement zones", () => { diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 385aa18..23a270f 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -16,6 +16,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; @@ -26,6 +27,7 @@ import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/ import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; @@ -49,6 +51,7 @@ import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, } from "../src/domain/models/distribution-board-structure-project-command.model.js"; +import { createDistributionBoardComponentInsertProjectCommand } from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; import { createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, @@ -122,6 +125,9 @@ function createService(context: DatabaseContext) { new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectSettingsProjectCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db), + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ), new ProjectHistoryRepository(context.db) ); } @@ -1059,6 +1065,62 @@ describe("project command service", () => { } }); + it("dispatches distribution-board component insertion and deletion", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const created = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createDistributionBoardComponentInsertProjectCommand({ + component: { + id: "component-service", + circuitListId: context.db + .select() + .from(circuitSections) + .get()!.circuitListId, + sectionId: null, + equipmentIdentifier: "-K10", + name: "KNX Aktor", + role: "auxiliary", + placement: "footer", + sortOrder: 10, + }, + protectionDevice: null, + }), + }); + assert.equal(created.history.currentRevision, 1); + assert.ok( + context.db + .select() + .from(distributionBoardComponents) + .where( + eq(distributionBoardComponents.id, "component-service") + ) + .get() + ); + + const undone = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.history.currentRevision, 2); + assert.equal( + context.db + .select() + .from(distributionBoardComponents) + .where( + eq(distributionBoardComponents.id, "component-service") + ) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + it("dispatches floor and room setup with their persisted inverses", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 73b2591..4a9bf2f 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -16,6 +16,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js"; +import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js"; import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; @@ -191,6 +192,9 @@ function createService(context: DatabaseContext) { new ProjectDeviceRowSyncProjectCommandRepository(context.db), new ProjectSettingsProjectCommandRepository(context.db), new ProjectStateRestoreCommandRepository(context.db), + new DistributionBoardComponentStructureProjectCommandRepository( + context.db + ), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 74602c1..9eb1987 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -115,6 +115,22 @@ describe("project version history presentation", () => { ), "Projekteinstellungen bearbeitet" ); + assert.equal( + getProjectRevisionDescription( + revision(5, { + commandType: "distribution-board-component.insert", + }) + ), + "Verteilergerät angelegt" + ); + assert.equal( + getProjectRevisionDescription( + revision(6, { + commandType: "distribution-board-component.delete", + }) + ), + "Verteilergerät entfernt" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),