From 99bdf6491b7f799a0b6bf5ee20c131559abb998c Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 20:10:10 +0200 Subject: [PATCH] Persist populated group deletion --- AGENTS.md | 6 + docs/current-architecture.md | 8 +- docs/spec/08-current-product-backlog.md | 2 +- ...-board-components-and-protection-groups.md | 14 +- ...roup-subtree-project-command.repository.ts | 257 +++++++++++++++++ ...uit-group-subtree-project-command.model.ts | 88 ++++++ ...uit-group-subtree-project-command.store.ts | 24 ++ .../services/project-command.service.ts | 22 ++ src/frontend/utils/project-version-history.ts | 2 + .../composition/project-command-stores.ts | 4 + tests/circuit-group-numbering.test.ts | 1 + ...subtree-project-command.repository.test.ts | 269 ++++++++++++++++++ tests/project-command.service.test.ts | 44 +++ ...t-state-restore-command.repository.test.ts | 2 + tests/project-version-history.test.ts | 16 ++ 15 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 src/db/repositories/circuit-group-subtree-project-command.repository.ts create mode 100644 src/domain/models/circuit-group-subtree-project-command.model.ts create mode 100644 src/domain/ports/circuit-group-subtree-project-command.store.ts create mode 100644 tests/circuit-group-subtree-project-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3fffdc5..e023890 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -326,6 +326,12 @@ groups of the same category. The stored target BMK is highest target suffix plus one at planning time and is never recalculated for Redo. Only circuit group, BMK and sort order change; device rows and circuit protection retain the stable circuit id. +Confirmed populated-group deletion uses +`circuit-group.delete-subtree`/`circuit-group.restore-subtree`. The exact +snapshot contains the group, optional component protections, complete circuits, +circuit protections and all device-row link/override metadata. Delete +re-captures and compares the subtree before cascading; restore preserves every +UUID in foreign-key-safe order. 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 eebd593..9b989c8 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -336,7 +336,13 @@ Gruppenoperationen. Er umfasst Gruppe, optionale Gruppenkomponenten samt 1:1-Schutzdaten, vollständige Stromkreise samt 1:1-Schutzdaten und sämtliche Gerätezeilen einschließlich Link- und Override-Metadaten. Seine Warnzusammenfassung liefert Vorsicherung/FI sowie Stromkreis- und -Gerätezeilenanzahl. Persistentes Löschen/Wiederherstellen folgt separat. +Gerätezeilenanzahl. +`circuit-group.delete-subtree` nimmt den aktuellen Unterbaum unmittelbar vor +dem Löschen erneut auf und verlangt exakte Übereinstimmung mit dem bestätigten +Snapshot. `circuit-group.restore-subtree` schreibt ihn in FK-sicherer Reihenfolge +mit denselben UUIDs zurück. Beide Richtungen laufen als vollständige inverse +Projekt-Commands; späte Fehler hinterlassen weder Teillöschungen noch +Teilwiederherstellungen. `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 diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 9603824..b718704 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -30,7 +30,7 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase D2a: deterministic same-category circuit-move plan. - [x] Phase D2b: persistent same-category circuit-move command. - [x] Phase D3a: complete validated group-subtree snapshot and warning summary. -- [ ] Phase D3b: persistent populated-group delete/restore command. +- [x] Phase D3b: persistent populated-group delete/restore command. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until 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 28d60bc..175241f 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -662,9 +662,7 @@ Acceptance: ### D. Group Numbering and Circuit Moves -Status: In progress. Group renumbering D1 and deterministic circuit-move -planning and persistent moves D2 plus the deletion snapshot D3a are complete; -persistent populated deletion D3b remains pending. +Status: Complete. - implement nested identifier generation - support same-category cross-group circuit moves @@ -721,6 +719,16 @@ Implemented in D3a: - the snapshot derives the confirmation summary for upstream protection, RCD, circuit count and device-row count +Implemented in D3b: + +- `circuit-group.delete-subtree` requires the exact previously confirmed + snapshot and re-captures the full current subtree before deletion +- `circuit-group.restore-subtree` restores stable UUIDs in foreign-key-safe + order, including both protection layers and row link/override metadata +- deletion and restoration are exact persistent inverses for Undo/Redo +- stale confirmation state and late history failures leave no partial delete + or restore + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/db/repositories/circuit-group-subtree-project-command.repository.ts b/src/db/repositories/circuit-group-subtree-project-command.repository.ts new file mode 100644 index 0000000..605146d --- /dev/null +++ b/src/db/repositories/circuit-group-subtree-project-command.repository.ts @@ -0,0 +1,257 @@ +import { asc, eq } from "drizzle-orm"; +import { + circuitGroupDeleteSubtreeCommandType, + circuitGroupRestoreSubtreeCommandType, + assertCircuitGroupDeleteSubtreeProjectCommand, + assertCircuitGroupRestoreSubtreeProjectCommand, + createCircuitGroupDeleteSubtreeProjectCommand, + createCircuitGroupRestoreSubtreeProjectCommand, + type CircuitGroupSubtreeProjectCommand, +} from "../../domain/models/circuit-group-subtree-project-command.model.js"; +import type { + CircuitGroupSubtreeSnapshot, + CircuitProtectionDeviceSnapshot, +} from "../../domain/models/circuit-group-subtree-snapshot.model.js"; +import type { + CircuitGroupSubtreeProjectCommandStore, + ExecuteCircuitGroupSubtreeCommandInput, +} from "../../domain/ports/circuit-group-subtree-project-command.store.js"; +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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class CircuitGroupSubtreeProjectCommandRepository + implements CircuitGroupSubtreeProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + capture(projectId: string, groupId: string) { + return this.captureFrom(this.database, projectId, groupId); + } + + execute(input: ExecuteCircuitGroupSubtreeCommandInput) { + if (input.command.type === circuitGroupDeleteSubtreeCommandType) { + assertCircuitGroupDeleteSubtreeProjectCommand(input.command); + } else { + assertCircuitGroupRestoreSubtreeProjectCommand(input.command); + } + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input.projectId, input.command) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: CircuitGroupSubtreeProjectCommand + ) { + if (command.type === circuitGroupDeleteSubtreeCommandType) { + const current = this.captureFrom( + database, + projectId, + command.payload.snapshot.group.id + ); + if (!sameSnapshot(current, command.payload.snapshot)) { + throw new Error( + "Circuit-group subtree changed before deletion." + ); + } + const deleted = database + .delete(circuitSections) + .where(eq(circuitSections.id, current.group.id)) + .run(); + if (deleted.changes !== 1) { + throw new Error("Circuit-group subtree could not be deleted."); + } + return createCircuitGroupRestoreSubtreeProjectCommand( + command.payload.snapshot + ); + } + if (command.type === circuitGroupRestoreSubtreeCommandType) { + this.restore(database, projectId, command.payload.snapshot); + return createCircuitGroupDeleteSubtreeProjectCommand( + command.payload.snapshot + ); + } + throw new Error("Unsupported circuit-group subtree command."); + } + + private captureFrom( + database: AppDatabase, + projectId: string, + groupId: string + ): CircuitGroupSubtreeSnapshot { + const group = database + .select() + .from(circuitSections) + .where(eq(circuitSections.id, groupId)) + .get(); + if (!group || group.category === null || group.groupNumber === null) { + throw new Error("Circuit group not found."); + } + this.assertListOwnership(database, projectId, group.circuitListId); + const components = database + .select() + .from(distributionBoardComponents) + .where(eq(distributionBoardComponents.sectionId, group.id)) + .orderBy( + asc(distributionBoardComponents.sortOrder), + asc(distributionBoardComponents.id) + ) + .all() + .map((component) => ({ + component: { + ...component, + role: component.role as + | "group_upstream_protection" + | "group_residual_current_protection", + }, + protectionDevice: + database + .select() + .from(distributionBoardComponentProtectionDevices) + .where( + eq( + distributionBoardComponentProtectionDevices.componentId, + component.id + ) + ) + .get() ?? null, + })); + const circuitSnapshots = database + .select() + .from(circuits) + .where(eq(circuits.sectionId, group.id)) + .orderBy(asc(circuits.sortOrder), asc(circuits.id)) + .all() + .map((circuit) => { + const { isReserve, ...values } = circuit; + return { + circuit: { + ...values, + isReserve: Boolean(isReserve), + deviceRows: database + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuit.id)) + .orderBy( + asc(circuitDeviceRows.sortOrder), + asc(circuitDeviceRows.id) + ) + .all(), + }, + protectionDevice: + (database + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuit.id)) + .get() as CircuitProtectionDeviceSnapshot | undefined) ?? + null, + }; + }); + return { + group: { + ...group, + category: group.category, + groupNumber: group.groupNumber, + }, + components, + circuits: circuitSnapshots, + }; + } + + private restore( + database: AppDatabase, + projectId: string, + snapshot: CircuitGroupSubtreeSnapshot + ) { + this.assertListOwnership( + database, + projectId, + snapshot.group.circuitListId + ); + const existing = database + .select({ id: circuitSections.id }) + .from(circuitSections) + .where(eq(circuitSections.id, snapshot.group.id)) + .get(); + if (existing) { + throw new Error("Circuit group already exists before restoration."); + } + database.insert(circuitSections).values(snapshot.group).run(); + for (const component of snapshot.components) { + database + .insert(distributionBoardComponents) + .values(component.component) + .run(); + if (component.protectionDevice !== null) { + database + .insert(distributionBoardComponentProtectionDevices) + .values(component.protectionDevice) + .run(); + } + } + for (const entry of snapshot.circuits) { + const { deviceRows, isReserve, ...circuit } = entry.circuit; + database + .insert(circuits) + .values({ ...circuit, isReserve: isReserve ? 1 : 0 }) + .run(); + if (entry.protectionDevice !== null) { + database + .insert(circuitProtectionDevices) + .values(entry.protectionDevice) + .run(); + } + if (deviceRows.length > 0) { + database.insert(circuitDeviceRows).values(deviceRows).run(); + } + } + } + + 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 list does not belong to project."); + } + } +} + +function sameSnapshot( + left: CircuitGroupSubtreeSnapshot, + right: CircuitGroupSubtreeSnapshot +) { + return canonicalJson(left) === canonicalJson(right); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (value !== null && typeof value === "object") { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([key, child]) => + `${JSON.stringify(key)}:${canonicalJson(child)}` + ) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/src/domain/models/circuit-group-subtree-project-command.model.ts b/src/domain/models/circuit-group-subtree-project-command.model.ts new file mode 100644 index 0000000..a3e53b3 --- /dev/null +++ b/src/domain/models/circuit-group-subtree-project-command.model.ts @@ -0,0 +1,88 @@ +import { + assertCircuitGroupSubtreeSnapshot, + type CircuitGroupSubtreeSnapshot, +} from "./circuit-group-subtree-snapshot.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitGroupDeleteSubtreeCommandType = + "circuit-group.delete-subtree" as const; +export const circuitGroupRestoreSubtreeCommandType = + "circuit-group.restore-subtree" as const; +export const circuitGroupSubtreeCommandSchemaVersion = 1 as const; + +interface Payload { + snapshot: CircuitGroupSubtreeSnapshot; +} + +export interface CircuitGroupDeleteSubtreeProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion; + type: typeof circuitGroupDeleteSubtreeCommandType; +} + +export interface CircuitGroupRestoreSubtreeProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupSubtreeCommandSchemaVersion; + type: typeof circuitGroupRestoreSubtreeCommandType; +} + +export type CircuitGroupSubtreeProjectCommand = + | CircuitGroupDeleteSubtreeProjectCommand + | CircuitGroupRestoreSubtreeProjectCommand; + +export function createCircuitGroupDeleteSubtreeProjectCommand( + snapshot: CircuitGroupSubtreeSnapshot +): CircuitGroupDeleteSubtreeProjectCommand { + const command: CircuitGroupDeleteSubtreeProjectCommand = { + schemaVersion: circuitGroupSubtreeCommandSchemaVersion, + type: circuitGroupDeleteSubtreeCommandType, + payload: { snapshot }, + }; + assertCircuitGroupDeleteSubtreeProjectCommand(command); + return command; +} + +export function createCircuitGroupRestoreSubtreeProjectCommand( + snapshot: CircuitGroupSubtreeSnapshot +): CircuitGroupRestoreSubtreeProjectCommand { + const command: CircuitGroupRestoreSubtreeProjectCommand = { + schemaVersion: circuitGroupSubtreeCommandSchemaVersion, + type: circuitGroupRestoreSubtreeCommandType, + payload: { snapshot }, + }; + assertCircuitGroupRestoreSubtreeProjectCommand(command); + return command; +} + +export function assertCircuitGroupDeleteSubtreeProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupDeleteSubtreeProjectCommand { + assertCommand(command, circuitGroupDeleteSubtreeCommandType); +} + +export function assertCircuitGroupRestoreSubtreeProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupRestoreSubtreeProjectCommand { + assertCommand(command, circuitGroupRestoreSubtreeCommandType); +} + +function assertCommand( + command: SerializedProjectCommand, + type: + | typeof circuitGroupDeleteSubtreeCommandType + | typeof circuitGroupRestoreSubtreeCommandType +) { + if ( + command.schemaVersion !== circuitGroupSubtreeCommandSchemaVersion || + command.type !== type || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported circuit-group subtree command."); + } + assertCircuitGroupSubtreeSnapshot(command.payload.snapshot); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-group-subtree-project-command.store.ts b/src/domain/ports/circuit-group-subtree-project-command.store.ts new file mode 100644 index 0000000..3f738a6 --- /dev/null +++ b/src/domain/ports/circuit-group-subtree-project-command.store.ts @@ -0,0 +1,24 @@ +import type { CircuitGroupSubtreeSnapshot } from "../models/circuit-group-subtree-snapshot.model.js"; +import type { CircuitGroupSubtreeProjectCommand } from "../models/circuit-group-subtree-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitGroupSubtreeCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitGroupSubtreeProjectCommand; +} + +export interface CircuitGroupSubtreeProjectCommandStore { + capture(projectId: string, groupId: string): CircuitGroupSubtreeSnapshot; + execute(input: ExecuteCircuitGroupSubtreeCommandInput): { + revision: AppendedProjectRevision; + inverse: CircuitGroupSubtreeProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 620a0b3..48a8526 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -101,6 +101,7 @@ import type { CircuitStructureProjectCommandStore } from "../ports/circuit-struc import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-structure-project-command.store.js"; import type { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js"; import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-project-command.store.js"; +import type { CircuitGroupSubtreeProjectCommandStore } from "../ports/circuit-group-subtree-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 { @@ -131,6 +132,12 @@ import { assertCircuitGroupMoveProjectCommand, circuitGroupMoveCommandType, } from "../models/circuit-group-move-project-command.model.js"; +import { + assertCircuitGroupDeleteSubtreeProjectCommand, + assertCircuitGroupRestoreSubtreeProjectCommand, + circuitGroupDeleteSubtreeCommandType, + circuitGroupRestoreSubtreeCommandType, +} from "../models/circuit-group-subtree-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"; @@ -169,6 +176,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore, private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore, + private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -399,6 +407,20 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitGroupDeleteSubtreeCommandType: { + assertCircuitGroupDeleteSubtreeProjectCommand(input.command); + return this.circuitGroupSubtreeStore.execute({ + ...input, + command: input.command, + }).revision; + } + case circuitGroupRestoreSubtreeCommandType: { + assertCircuitGroupRestoreSubtreeProjectCommand(input.command); + return this.circuitGroupSubtreeStore.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 344ac5d..4ced5a1 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -42,6 +42,8 @@ const commandTypeLabels: Record = { "circuit-group.reorder": "Stromkreisgruppen sortiert", "circuit-group.renumber": "Stromkreisgruppen neu nummeriert", "circuit.move-group": "Stromkreis in andere Gruppe verschoben", + "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", + "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", "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 17b7b0d..f42a606 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -9,6 +9,7 @@ import { CircuitStructureProjectCommandRepository } from "../../db/repositories/ import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js"; +import { CircuitGroupSubtreeProjectCommandRepository } from "../../db/repositories/circuit-group-subtree-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"; @@ -35,6 +36,8 @@ export const circuitGroupRenumberProjectCommandStore = new CircuitGroupRenumberProjectCommandRepository(db); export const circuitGroupMoveProjectCommandStore = new CircuitGroupMoveProjectCommandRepository(db); +export const circuitGroupSubtreeProjectCommandStore = + new CircuitGroupSubtreeProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); export const distributionBoardComponentStructureProjectCommandStore = @@ -75,5 +78,6 @@ export const projectCommandService = new ProjectCommandService( circuitGroupStructureProjectCommandStore, circuitGroupRenumberProjectCommandStore, circuitGroupMoveProjectCommandStore, + circuitGroupSubtreeProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index e481435..957b9b8 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -14,6 +14,7 @@ import "./circuit-group-renumber-project-command.repository.test.js"; import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js"; import "./circuit-group-move-project-command.repository.test.js"; import "./circuit-group-subtree-snapshot.test.js"; +import "./circuit-group-subtree-project-command.repository.test.js"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { diff --git a/tests/circuit-group-subtree-project-command.repository.test.ts b/tests/circuit-group-subtree-project-command.repository.test.ts new file mode 100644 index 0000000..2fc5469 --- /dev/null +++ b/tests/circuit-group-subtree-project-command.repository.test.ts @@ -0,0 +1,269 @@ +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 { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { 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 { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-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" }) + .run(); + new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const list = context.db.select().from(circuitLists).get()!; + const group = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.category, "lighting")) + .get()!; + context.db + .insert(distributionBoardComponents) + .values({ + id: "subtree-rcd", + circuitListId: list.id, + sectionId: group.id, + equipmentIdentifier: "-1Q1.0", + name: "Gruppen-FI", + role: "group_residual_current_protection", + placement: "group", + sortOrder: 10, + }) + .run(); + context.db + .insert(distributionBoardComponentProtectionDevices) + .values({ + componentId: "subtree-rcd", + type: "FI", + ratedCurrentA: 40, + rcdType: "A", + ratedResidualCurrentMa: 30, + }) + .run(); + context.db + .insert(circuits) + .values({ + id: "subtree-circuit", + circuitListId: list.id, + sectionId: group.id, + equipmentIdentifier: "-1F1.1", + displayName: "Beleuchtung", + sortOrder: 10, + voltage: 230, + isReserve: 0, + }) + .run(); + context.db + .insert(circuitProtectionDevices) + .values({ + circuitId: "subtree-circuit", + type: "LS", + ratedCurrentA: 10, + tripCharacteristic: "B", + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "subtree-row", + circuitId: "subtree-circuit", + linkedProjectDeviceId: null, + legacyConsumerId: null, + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + phaseType: "single_phase", + connectionKind: null, + costGroup: null, + category: null, + level: null, + roomId: null, + roomNumberSnapshot: null, + roomNameSnapshot: null, + quantity: 2, + powerPerUnit: 0.03, + simultaneityFactor: 1, + cosPhi: 0.95, + remark: null, + overriddenFields: "[\"displayName\"]", + }) + .run(); + return context; +} + +describe("circuit-group subtree project command", () => { + it("deletes and restores the exact populated subtree through undo/redo", () => { + const context = createTestDatabase(); + try { + const repository = + new CircuitGroupSubtreeProjectCommandRepository(context.db); + const groupId = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "subtree-circuit")) + .get()!.sectionId; + const snapshot = repository.capture("project-1", groupId); + const command = + createCircuitGroupDeleteSubtreeProjectCommand(snapshot); + const deleted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, groupId)) + .get(), + undefined + ); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "subtree-row")) + .get(), + undefined + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: deleted.inverse, + }); + assert.deepEqual( + repository.capture("project-1", groupId), + snapshot + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "redo")?.changeSetId, + command, + }); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "subtree-circuit")) + .get(), + undefined + ); + } finally { + context.close(); + } + }); + + it("rejects a subtree changed after confirmation", () => { + const context = createTestDatabase(); + try { + const repository = + new CircuitGroupSubtreeProjectCommandRepository(context.db); + const groupId = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "subtree-circuit")) + .get()!.sectionId; + const snapshot = repository.capture("project-1", groupId); + context.db + .update(circuitDeviceRows) + .set({ displayName: "Geändert" }) + .where(eq(circuitDeviceRows.id, "subtree-row")) + .run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createCircuitGroupDeleteSubtreeProjectCommand(snapshot), + }), + /changed before deletion/ + ); + assert.ok( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, groupId)) + .get() + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rolls back a cascaded deletion when history persistence fails", () => { + const context = createTestDatabase(); + try { + const repository = + new CircuitGroupSubtreeProjectCommandRepository(context.db); + const groupId = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "subtree-circuit")) + .get()!.sectionId; + const snapshot = repository.capture("project-1", groupId); + context.sqlite.exec(` + CREATE TRIGGER fail_group_subtree_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced group subtree history failure'); + END; + `); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: + createCircuitGroupDeleteSubtreeProjectCommand(snapshot), + }), + /forced group subtree history failure/ + ); + assert.deepEqual( + repository.capture("project-1", groupId), + snapshot + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 6943687..b1988e5 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; +import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-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"; @@ -59,6 +60,7 @@ import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/c import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js"; import { createCircuitGroupMoveProjectCommand } from "../src/domain/models/circuit-group-move-project-command.model.js"; import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js"; +import { createCircuitGroupDeleteSubtreeProjectCommand } from "../src/domain/models/circuit-group-subtree-project-command.model.js"; import { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, @@ -146,6 +148,7 @@ function createService(context: DatabaseContext) { new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db), + new CircuitGroupSubtreeProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -1447,6 +1450,47 @@ describe("project command service", () => { } }); + it("dispatches populated circuit-group deletion and restoration", () => { + const context = createTestDatabase(); + try { + const repository = + new CircuitGroupSubtreeProjectCommandRepository(context.db); + const groupId = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get()!.sectionId; + const snapshot = repository.capture("project-1", groupId); + const deleted = createService(context).executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: + createCircuitGroupDeleteSubtreeProjectCommand(snapshot), + }); + assert.equal(deleted.history.currentRevision, 1); + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, groupId)) + .get(), + undefined + ); + + const restored = createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(restored.history.currentRevision, 2); + assert.deepEqual( + repository.capture("project-1", groupId), + snapshot + ); + } 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 5900471..26c8c51 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -17,6 +17,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; +import { CircuitGroupSubtreeProjectCommandRepository } from "../src/db/repositories/circuit-group-subtree-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"; @@ -201,6 +202,7 @@ function createService(context: DatabaseContext) { new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db), + new CircuitGroupSubtreeProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 3cb2c25..300b972 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -175,6 +175,22 @@ describe("project version history presentation", () => { ), "Stromkreis in andere Gruppe verschoben" ); + assert.equal( + getProjectRevisionDescription( + revision(14, { + commandType: "circuit-group.delete-subtree", + }) + ), + "Stromkreisgruppe vollständig entfernt" + ); + assert.equal( + getProjectRevisionDescription( + revision(15, { + commandType: "circuit-group.restore-subtree", + }) + ), + "Stromkreisgruppe vollständig wiederhergestellt" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),