From 1250f9a6af499fa5f54875a3fd2543282d0f0d53 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 19:46:55 +0200 Subject: [PATCH] Add circuit group commands --- AGENTS.md | 5 + docs/current-architecture.md | 12 +- docs/spec/08-current-product-backlog.md | 3 +- ...-board-components-and-protection-groups.md | 16 +- ...up-structure-project-command.repository.ts | 189 +++++++++++ ...t-group-structure-project-command.model.ts | 192 ++++++++++++ ...t-group-structure-project-command.store.ts | 22 ++ .../services/circuit-group-numbering.ts | 7 + .../services/project-command.service.ts | 31 ++ src/frontend/utils/project-version-history.ts | 3 + .../composition/project-command-stores.ts | 4 + tests/circuit-group-numbering.test.ts | 1 + ...ructure-project-command.repository.test.ts | 296 ++++++++++++++++++ tests/project-command.service.test.ts | 68 ++++ ...t-state-restore-command.repository.test.ts | 2 + tests/project-version-history.test.ts | 18 ++ 16 files changed, 864 insertions(+), 5 deletions(-) create mode 100644 src/db/repositories/circuit-group-structure-project-command.repository.ts create mode 100644 src/domain/models/circuit-group-structure-project-command.model.ts create mode 100644 src/domain/ports/circuit-group-structure-project-command.store.ts create mode 100644 tests/circuit-group-structure-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7795438..76a6289 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -309,6 +309,11 @@ snapshots include the optional one-to-one protection-device state and reject stale data. Updates preserve id, ownership, role and placement. Fixed main-switch and surge-protection header roles are excluded from these general component commands. +Empty circuit-group creation, display-name updates and deletion use +`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`. +Their snapshots keep category, positive group number and derived prefix +consistent. General deletion rejects groups containing circuits or group +components; populated deletion remains a separate confirmed command. 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 3141081..03449e2 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -298,6 +298,13 @@ veraltete Zustände 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. +Leere Stromkreisgruppen werden über `circuit-group.insert`, +`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger +Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten +BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen. +Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne +Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein +eigener späterer Command. `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 @@ -369,8 +376,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für - veränderliche Verteilerkomponenten sind integriert; Gruppen- und UI-Schritte - folgen in abgegrenzten Arbeitspaketen. + veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen + sind integriert; Gruppensortierung, befüllte Unterbäume und UI 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 cc12925..d9f0f40 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -23,7 +23,8 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase C2a1: persistent insert/delete commands for mutable group protection and auxiliary components. - [x] Phase C2a2: component update and reorder commands. -- [ ] Phase C2b: group CRUD and reorder commands. +- [x] Phase C2b1: empty-group insert/update/delete commands. +- [ ] Phase C2b2: complete group reorder command. - [ ] 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 3d908d7..ae02420 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 and component management C2a are -complete. Group management C2b remains pending. +Status: In progress. New-board defaults C1, component management C2a and +empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains +pending. - extend `distribution-board.insert` with fixed components and three groups - implement component and group CRUD commands @@ -635,6 +636,17 @@ Implemented in C2a2: - stale expected state and cross-entity BMK collisions are rejected; Undo/Redo restores the exact previous snapshot +Implemented in C2b1: + +- `circuit-group.insert`, `circuit-group.update` and + `circuit-group.delete` persist complete group snapshots with stable UUIDs +- category, group number and prefix are validated as one consistent identity; + general updates change only the display name +- deletion requires an exact unchanged and empty group; populated destructive + deletion remains the separately confirmed Phase-D operation +- project/list ownership, stale state, Undo/Redo and late rollback are covered + at the shared transaction boundary + Acceptance: - new boards contain the agreed fixed structure diff --git a/src/db/repositories/circuit-group-structure-project-command.repository.ts b/src/db/repositories/circuit-group-structure-project-command.repository.ts new file mode 100644 index 0000000..6f4b264 --- /dev/null +++ b/src/db/repositories/circuit-group-structure-project-command.repository.ts @@ -0,0 +1,189 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitGroupDeleteProjectCommand, + assertCircuitGroupInsertProjectCommand, + assertCircuitGroupUpdateProjectCommand, + circuitGroupDeleteCommandType, + circuitGroupInsertCommandType, + circuitGroupUpdateCommandType, + createCircuitGroupDeleteProjectCommand, + createCircuitGroupInsertProjectCommand, + createCircuitGroupUpdateProjectCommand, + type CircuitGroupSnapshot, + type CircuitGroupStructureProjectCommand, +} from "../../domain/models/circuit-group-structure-project-command.model.js"; +import type { + CircuitGroupStructureProjectCommandStore, + ExecuteCircuitGroupStructureCommandInput, +} from "../../domain/ports/circuit-group-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 { circuits } from "../schema/circuits.js"; +import { distributionBoardComponents } from "../schema/distribution-board-components.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class CircuitGroupStructureProjectCommandRepository + implements CircuitGroupStructureProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitGroupStructureCommandInput) { + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input.projectId, input.command) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: CircuitGroupStructureProjectCommand + ): CircuitGroupStructureProjectCommand { + if (command.type === circuitGroupInsertCommandType) { + assertCircuitGroupInsertProjectCommand(command); + this.insert(database, projectId, command.payload.snapshot); + return createCircuitGroupDeleteProjectCommand( + command.payload.snapshot + ); + } + if (command.type === circuitGroupDeleteCommandType) { + assertCircuitGroupDeleteProjectCommand(command); + this.delete(database, projectId, command.payload.snapshot); + return createCircuitGroupInsertProjectCommand( + command.payload.snapshot + ); + } + if (command.type === circuitGroupUpdateCommandType) { + assertCircuitGroupUpdateProjectCommand(command); + this.update( + database, + projectId, + command.payload.expected, + command.payload.target + ); + return createCircuitGroupUpdateProjectCommand( + command.payload.target, + command.payload.expected + ); + } + throw new Error("Unsupported circuit-group structure command."); + } + + private insert( + database: AppDatabase, + projectId: string, + snapshot: CircuitGroupSnapshot + ) { + this.assertListOwnership(database, projectId, snapshot.circuitListId); + const existing = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.id)) + .get(); + if (existing) { + throw new Error("Circuit-group id already exists."); + } + database.insert(circuitSections).values(snapshot).run(); + } + + private delete( + database: AppDatabase, + projectId: string, + snapshot: CircuitGroupSnapshot + ) { + this.assertListOwnership(database, projectId, snapshot.circuitListId); + const current = this.getCurrent(database, snapshot.id); + if (!current || !sameRecord(snapshot, current)) { + throw new Error("Circuit group changed before deletion."); + } + const circuit = database + .select({ id: circuits.id }) + .from(circuits) + .where(eq(circuits.sectionId, snapshot.id)) + .get(); + const component = database + .select({ id: distributionBoardComponents.id }) + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.sectionId, snapshot.id)) + .get(); + if (circuit || component) { + throw new Error( + "Only empty circuit groups can be deleted by this command." + ); + } + const deleted = database + .delete(circuitSections) + .where( + and( + eq(circuitSections.id, snapshot.id), + eq(circuitSections.circuitListId, snapshot.circuitListId) + ) + ) + .run(); + if (deleted.changes !== 1) { + throw new Error("Circuit group could not be deleted."); + } + } + + private update( + database: AppDatabase, + projectId: string, + expected: CircuitGroupSnapshot, + target: CircuitGroupSnapshot + ) { + this.assertListOwnership(database, projectId, expected.circuitListId); + const current = this.getCurrent(database, expected.id); + if (!current || !sameRecord(expected, current)) { + throw new Error("Circuit group changed before update."); + } + const updated = database + .update(circuitSections) + .set({ displayName: target.displayName }) + .where( + and( + eq(circuitSections.id, expected.id), + eq(circuitSections.circuitListId, expected.circuitListId) + ) + ) + .run(); + if (updated.changes !== 1) { + throw new Error("Circuit group could not be updated."); + } + } + + private assertListOwnership( + database: AppDatabase, + projectId: string, + circuitListId: string + ) { + const list = database + .select({ projectId: circuitLists.projectId }) + .from(circuitLists) + .where(eq(circuitLists.id, circuitListId)) + .get(); + if (!list || list.projectId !== projectId) { + throw new Error("Circuit group does not belong to project."); + } + } + + private getCurrent(database: AppDatabase, id: string) { + return database + .select() + .from(circuitSections) + .where(eq(circuitSections.id, id)) + .get(); + } +} + +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/circuit-group-structure-project-command.model.ts b/src/domain/models/circuit-group-structure-project-command.model.ts new file mode 100644 index 0000000..4430c80 --- /dev/null +++ b/src/domain/models/circuit-group-structure-project-command.model.ts @@ -0,0 +1,192 @@ +import { + circuitGroupCategories, + type CircuitGroupCategory, +} from "../../shared/constants/circuit-group.js"; +import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitGroupInsertCommandType = "circuit-group.insert" as const; +export const circuitGroupDeleteCommandType = "circuit-group.delete" as const; +export const circuitGroupUpdateCommandType = "circuit-group.update" as const; +export const circuitGroupStructureCommandSchemaVersion = 1 as const; + +export interface CircuitGroupSnapshot { + id: string; + circuitListId: string; + key: string; + displayName: string; + prefix: string; + sortOrder: number; + category: CircuitGroupCategory; + groupNumber: number; +} + +interface SnapshotPayload { + snapshot: CircuitGroupSnapshot; +} + +interface UpdatePayload { + expected: CircuitGroupSnapshot; + target: CircuitGroupSnapshot; +} + +export interface CircuitGroupInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; + type: typeof circuitGroupInsertCommandType; +} + +export interface CircuitGroupDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; + type: typeof circuitGroupDeleteCommandType; +} + +export interface CircuitGroupUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; + type: typeof circuitGroupUpdateCommandType; +} + +export type CircuitGroupStructureProjectCommand = + | CircuitGroupInsertProjectCommand + | CircuitGroupDeleteProjectCommand + | CircuitGroupUpdateProjectCommand; + +export function createCircuitGroupInsertProjectCommand( + snapshot: CircuitGroupSnapshot +): CircuitGroupInsertProjectCommand { + const command: CircuitGroupInsertProjectCommand = { + schemaVersion: circuitGroupStructureCommandSchemaVersion, + type: circuitGroupInsertCommandType, + payload: { snapshot }, + }; + assertCircuitGroupInsertProjectCommand(command); + return command; +} + +export function createCircuitGroupDeleteProjectCommand( + snapshot: CircuitGroupSnapshot +): CircuitGroupDeleteProjectCommand { + const command: CircuitGroupDeleteProjectCommand = { + schemaVersion: circuitGroupStructureCommandSchemaVersion, + type: circuitGroupDeleteCommandType, + payload: { snapshot }, + }; + assertCircuitGroupDeleteProjectCommand(command); + return command; +} + +export function createCircuitGroupUpdateProjectCommand( + expected: CircuitGroupSnapshot, + target: CircuitGroupSnapshot +): CircuitGroupUpdateProjectCommand { + const command: CircuitGroupUpdateProjectCommand = { + schemaVersion: circuitGroupStructureCommandSchemaVersion, + type: circuitGroupUpdateCommandType, + payload: { expected, target }, + }; + assertCircuitGroupUpdateProjectCommand(command); + return command; +} + +export function assertCircuitGroupInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupInsertProjectCommand { + assertSnapshotCommand(command, circuitGroupInsertCommandType); +} + +export function assertCircuitGroupDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupDeleteProjectCommand { + assertSnapshotCommand(command, circuitGroupDeleteCommandType); +} + +export function assertCircuitGroupUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupUpdateProjectCommand { + if ( + command.schemaVersion !== circuitGroupStructureCommandSchemaVersion || + command.type !== circuitGroupUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported circuit-group update command."); + } + assertCircuitGroupSnapshot(command.payload.expected); + assertCircuitGroupSnapshot(command.payload.target); + for (const field of [ + "id", + "circuitListId", + "key", + "prefix", + "sortOrder", + "category", + "groupNumber", + ] as const) { + if (command.payload.expected[field] !== command.payload.target[field]) { + throw new Error( + "Circuit-group updates can only change the display name." + ); + } + } +} + +function assertSnapshotCommand( + command: SerializedProjectCommand, + type: + | typeof circuitGroupInsertCommandType + | typeof circuitGroupDeleteCommandType +) { + if ( + command.schemaVersion !== circuitGroupStructureCommandSchemaVersion || + command.type !== type || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported circuit-group structure command."); + } + assertCircuitGroupSnapshot(command.payload.snapshot); +} + +export function assertCircuitGroupSnapshot( + value: unknown +): asserts value is CircuitGroupSnapshot { + if (!isPlainObject(value) || Object.keys(value).length !== 8) { + throw new Error("Circuit-group snapshot is invalid."); + } + for (const field of [ + "id", + "circuitListId", + "key", + "displayName", + "prefix", + ] as const) { + if (typeof value[field] !== "string" || !value[field].trim()) { + throw new Error(`${field} must be a non-empty string.`); + } + } + if ( + !circuitGroupCategories.includes( + value.category as CircuitGroupCategory + ) || + !Number.isInteger(value.groupNumber) || + (value.groupNumber as number) < 1 || + !Number.isFinite(value.sortOrder) + ) { + throw new Error("Circuit-group category, number or order is invalid."); + } + if ( + value.prefix !== + formatCircuitGroupPrefix( + value.category as CircuitGroupCategory, + value.groupNumber as number + ) + ) { + throw new Error("Circuit-group prefix does not match its category and number."); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-group-structure-project-command.store.ts b/src/domain/ports/circuit-group-structure-project-command.store.ts new file mode 100644 index 0000000..fb56cd8 --- /dev/null +++ b/src/domain/ports/circuit-group-structure-project-command.store.ts @@ -0,0 +1,22 @@ +import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitGroupStructureCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitGroupStructureProjectCommand; +} + +export interface CircuitGroupStructureProjectCommandStore { + execute(input: ExecuteCircuitGroupStructureCommandInput): { + revision: AppendedProjectRevision; + inverse: CircuitGroupStructureProjectCommand; + }; +} diff --git a/src/domain/services/circuit-group-numbering.ts b/src/domain/services/circuit-group-numbering.ts index 938bc26..e8cec55 100644 --- a/src/domain/services/circuit-group-numbering.ts +++ b/src/domain/services/circuit-group-numbering.ts @@ -36,6 +36,13 @@ function getGroupStem( return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`; } +export function formatCircuitGroupPrefix( + category: CircuitGroupCategory, + groupNumber: number +) { + return `${getGroupStem(category, "F", groupNumber)}.`; +} + export function formatGroupUpstreamProtectionIdentifier( category: CircuitGroupCategory, groupNumber: number diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index b5ce810..6b09bbd 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -98,6 +98,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js"; 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 { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-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 { @@ -110,6 +111,14 @@ import type { ProjectHistoryDirection, ProjectHistoryStore, } from "../ports/project-history.store.js"; +import { + assertCircuitGroupDeleteProjectCommand, + assertCircuitGroupInsertProjectCommand, + assertCircuitGroupUpdateProjectCommand, + circuitGroupDeleteCommandType, + circuitGroupInsertCommandType, + circuitGroupUpdateCommandType, +} from "../models/circuit-group-structure-project-command.model.js"; import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js"; import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js"; import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js"; @@ -145,6 +154,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly projectSettingsStore: ProjectSettingsProjectCommandStore, private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore, private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore, + private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -333,6 +343,27 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitGroupInsertCommandType: { + assertCircuitGroupInsertProjectCommand(input.command); + return this.circuitGroupStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case circuitGroupDeleteCommandType: { + assertCircuitGroupDeleteProjectCommand(input.command); + return this.circuitGroupStructureStore.execute({ + ...input, + command: input.command, + }).revision; + } + case circuitGroupUpdateCommandType: { + assertCircuitGroupUpdateProjectCommand(input.command); + return this.circuitGroupStructureStore.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 ca88f1e..19b9727 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -36,6 +36,9 @@ const commandTypeLabels: Record = { "distribution-board-component.insert": "Verteilergerät angelegt", "distribution-board-component.delete": "Verteilergerät entfernt", "distribution-board-component.update": "Verteilergerät bearbeitet", + "circuit-group.insert": "Stromkreisgruppe angelegt", + "circuit-group.delete": "Stromkreisgruppe entfernt", + "circuit-group.update": "Stromkreisgruppe bearbeitet", "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 a02893d..25858bb 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-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"; @@ -26,6 +27,8 @@ export const circuitDeviceRowMoveProjectCommandStore = new CircuitDeviceRowMoveProjectCommandRepository(db); export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); +export const circuitGroupStructureProjectCommandStore = + new CircuitGroupStructureProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); export const distributionBoardComponentStructureProjectCommandStore = @@ -63,5 +66,6 @@ export const projectCommandService = new ProjectCommandService( projectSettingsProjectCommandStore, projectStateRestoreCommandStore, distributionBoardComponentStructureProjectCommandStore, + circuitGroupStructureProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 8128d93..cc5e7dd 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -8,6 +8,7 @@ import { getNextGroupedCircuitIdentifier, parseGroupedEquipmentIdentifier, } from "../src/domain/services/circuit-group-numbering.js"; +import "./circuit-group-structure-project-command.repository.test.js"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { diff --git a/tests/circuit-group-structure-project-command.repository.test.ts b/tests/circuit-group-structure-project-command.repository.test.ts new file mode 100644 index 0000000..3b501d7 --- /dev/null +++ b/tests/circuit-group-structure-project-command.repository.test.ts @@ -0,0 +1,296 @@ +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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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 { circuits } from "../src/db/schema/circuits.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createCircuitGroupDeleteProjectCommand, + createCircuitGroupInsertProjectCommand, + createCircuitGroupUpdateProjectCommand, + type CircuitGroupSnapshot, +} from "../src/domain/models/circuit-group-structure-project-command.model.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"); + new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-2", "UV-02"); + return context; +} + +function getProjectList(context: DatabaseContext, projectId: string) { + const list = context.db + .select() + .from(circuitLists) + .where(eq(circuitLists.projectId, projectId)) + .get(); + assert.ok(list); + return list; +} + +function groupSnapshot(circuitListId: string): CircuitGroupSnapshot { + return { + id: "group-lighting-2", + circuitListId, + key: "lighting_2", + displayName: "Beleuchtung 2", + prefix: "-1F2.", + sortOrder: 40, + category: "lighting", + groupNumber: 2, + }; +} + +describe("circuit-group structure project command", () => { + it("inserts, renames and restores a group through persistent history", () => { + const context = createTestDatabase(); + try { + const expected = groupSnapshot( + getProjectList(context, "project-1").id + ); + const target = { + ...expected, + displayName: "Beleuchtung Besprechungsräume", + }; + const repository = + new CircuitGroupStructureProjectCommandRepository(context.db); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupInsertProjectCommand(expected), + }); + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitGroupUpdateProjectCommand( + expected, + target + ), + }); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, expected.id)) + .get()?.displayName, + target.displayName + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: updated.inverse, + }); + assert.deepEqual( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, expected.id)) + .get(), + expected + ); + } finally { + context.close(); + } + }); + + it("deletes only an exact empty group and restores the same UUID", () => { + const context = createTestDatabase(); + try { + const snapshot = groupSnapshot( + getProjectList(context, "project-1").id + ); + const repository = + new CircuitGroupStructureProjectCommandRepository(context.db); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupInsertProjectCommand(snapshot), + }); + const deleted = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitGroupDeleteProjectCommand(snapshot), + }); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.id)) + .get(), + undefined + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: deleted.inverse, + }); + assert.deepEqual( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.id)) + .get(), + snapshot + ); + } finally { + context.close(); + } + }); + + it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => { + const context = createTestDatabase(); + try { + const snapshot = groupSnapshot( + getProjectList(context, "project-1").id + ); + const repository = + new CircuitGroupStructureProjectCommandRepository(context.db); + assert.throws( + () => + createCircuitGroupInsertProjectCommand({ + ...snapshot, + prefix: "-1F9.", + }), + /prefix does not match/ + ); + assert.throws( + () => + repository.execute({ + projectId: "project-2", + expectedRevision: 0, + source: "user", + command: createCircuitGroupInsertProjectCommand(snapshot), + }), + /does not belong/ + ); + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupInsertProjectCommand(snapshot), + }); + context.db + .insert(circuits) + .values({ + id: "group-circuit", + circuitListId: snapshot.circuitListId, + sectionId: snapshot.id, + equipmentIdentifier: "-1F2.1", + displayName: "Beleuchtung", + sortOrder: 10, + }) + .run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitGroupDeleteProjectCommand(snapshot), + }), + /Only empty circuit groups/ + ); + context.db + .update(circuitSections) + .set({ displayName: "Direkt geändert" }) + .where(eq(circuitSections.id, snapshot.id)) + .run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitGroupUpdateProjectCommand(snapshot, { + ...snapshot, + displayName: "Ziel", + }), + }), + /changed before update/ + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 1 + ); + } finally { + context.close(); + } + }); + + it("rolls back group insertion when history persistence fails", () => { + const context = createTestDatabase(); + try { + const snapshot = groupSnapshot( + getProjectList(context, "project-1").id + ); + context.sqlite.exec(` + CREATE TRIGGER fail_group_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced group history failure'); + END; + `); + assert.throws( + () => + new CircuitGroupStructureProjectCommandRepository( + context.db + ).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupInsertProjectCommand(snapshot), + }), + /forced group history failure/ + ); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.id)) + .get(), + undefined + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 92e8f81..91896fe 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit- import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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"; @@ -47,6 +48,10 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/ import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js"; import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; +import { + createCircuitGroupInsertProjectCommand, + createCircuitGroupUpdateProjectCommand, +} from "../src/domain/models/circuit-group-structure-project-command.model.js"; import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, @@ -131,6 +136,7 @@ function createService(context: DatabaseContext) { new DistributionBoardComponentStructureProjectCommandRepository( context.db ), + new CircuitGroupStructureProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -1207,6 +1213,68 @@ describe("project command service", () => { } }); + it("dispatches circuit-group structure commands", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const circuitListId = context.db + .select() + .from(circuitSections) + .get()!.circuitListId; + const expected = { + id: "group-service", + circuitListId, + key: "lighting_2", + displayName: "Beleuchtung 2", + prefix: "-1F2.", + sortOrder: 40, + category: "lighting" as const, + groupNumber: 2, + }; + const target = { + ...expected, + displayName: "Beleuchtung Nebenräume", + }; + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitGroupInsertProjectCommand(expected), + }); + const updated = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createCircuitGroupUpdateProjectCommand( + expected, + target + ), + }); + assert.equal(updated.history.currentRevision, 2); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, expected.id)) + .get()?.displayName, + target.displayName + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 2, + }); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, expected.id)) + .get()?.displayName, + expected.displayName + ); + } 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 4a9bf2f..2f50e5a 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit- import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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"; @@ -195,6 +196,7 @@ function createService(context: DatabaseContext) { new DistributionBoardComponentStructureProjectCommandRepository( context.db ), + new CircuitGroupStructureProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index b3053de..2d9891f 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -139,6 +139,24 @@ describe("project version history presentation", () => { ), "Verteilergerät bearbeitet" ); + assert.equal( + getProjectRevisionDescription( + revision(8, { commandType: "circuit-group.insert" }) + ), + "Stromkreisgruppe angelegt" + ); + assert.equal( + getProjectRevisionDescription( + revision(9, { commandType: "circuit-group.delete" }) + ), + "Stromkreisgruppe entfernt" + ); + assert.equal( + getProjectRevisionDescription( + revision(10, { commandType: "circuit-group.update" }) + ), + "Stromkreisgruppe bearbeitet" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),