diff --git a/AGENTS.md b/AGENTS.md index d205167..3fffdc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,6 +321,11 @@ Explicit group-number changes use `circuit-group.renumber`. The command carries the complete expected/target group, circuit-BMK and optional group-component BMK plan, applies swaps through collision-safe temporary values and preserves circuit suffixes. Undo/Redo uses the exact inverse plan. +Cross-group circuit moves use `circuit.move-group` and are limited to distinct +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. 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 ebceb5a..5b4fccf 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -325,7 +325,12 @@ vollständigen Stromkreises zwischen zwei verschiedenen Gruppen derselben Kategorie. Die Ziel-BMK verwendet die höchste vorhandene Stromkreis-Endnummer der Zielgruppe plus eins; Lücken werden nicht gefüllt. Quell-/Zielgruppe, Quell-/Zielposition und beide BMKs werden festgeschrieben, damit eine spätere -Wiederholung nichts neu berechnet. Die persistente Ausführung folgt separat. +Wiederholung nichts neu berechnet. +`circuit.move-group` führt den geplanten Wechsel als eine Projekt-Revision aus. +Nur `sectionId`, `equipmentIdentifier` und `sortOrder` des Stromkreises ändern +sich. Gerätezeilen und das getrennte 1:1-Schutzgerät bleiben über die stabile +Stromkreis-UUID verbunden. Der inverse Wechsel speichert Quellgruppe, BMK und +Position vollständig für dauerhaftes Undo/Redo. `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 c80063d..8cbe856 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -28,7 +28,7 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase D1a: deterministic collision-aware group-renumber plan. - [x] Phase D1b: persistent collision-safe group-renumber command. - [x] Phase D2a: deterministic same-category circuit-move plan. -- [ ] Phase D2b: persistent same-category circuit-move command. +- [x] Phase D2b: persistent same-category circuit-move command. - [ ] Phase D3: confirmed populated-group deletion. - [ ] 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 7e2569f..8cfadb3 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -663,8 +663,8 @@ Acceptance: ### D. Group Numbering and Circuit Moves Status: In progress. Group renumbering D1 and deterministic circuit-move -planning D2a are complete; persistent moves D2b and populated deletion D3 -remain pending. +planning and persistent moves D2 are complete; populated deletion D3 remains +pending. - implement nested identifier generation - support same-category cross-group circuit moves @@ -700,6 +700,17 @@ Implemented in D2a: and never fills gaps - the computed target BMK is part of the plan and is not recalculated for Redo +Implemented in D2b: + +- `circuit.move-group` atomically changes only the circuit's group, BMK and + sort position +- the circuit UUID remains stable, so its device rows and one-to-one protection + device remain connected without copying +- execution revalidates project/list ownership, both group identities and the + complete expected circuit state +- Undo/Redo uses the stored inverse move and late failures restore source + group, BMK and position + Acceptance: - target identifiers use highest suffix plus one diff --git a/src/db/repositories/circuit-group-move-project-command.repository.ts b/src/db/repositories/circuit-group-move-project-command.repository.ts new file mode 100644 index 0000000..877e134 --- /dev/null +++ b/src/db/repositories/circuit-group-move-project-command.repository.ts @@ -0,0 +1,104 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitGroupMoveProjectCommand, + createCircuitGroupMoveProjectCommand, + invertCircuitGroupMovePlan, +} from "../../domain/models/circuit-group-move-project-command.model.js"; +import type { + CircuitGroupMoveProjectCommandStore, + ExecuteCircuitGroupMoveCommandInput, +} from "../../domain/ports/circuit-group-move-project-command.store.js"; +import { parseGroupedEquipmentIdentifier } from "../../domain/services/circuit-group-numbering.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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class CircuitGroupMoveProjectCommandRepository + implements CircuitGroupMoveProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitGroupMoveCommandInput) { + assertCircuitGroupMoveProjectCommand(input.command); + return executeProjectCommandTransaction( + this.database, + input, + (tx) => this.applyCommand(tx, input) + ); + } + + private applyCommand( + database: AppDatabase, + input: ExecuteCircuitGroupMoveCommandInput + ) { + const plan = input.command.payload; + const list = database + .select({ projectId: circuitLists.projectId }) + .from(circuitLists) + .where(eq(circuitLists.id, plan.circuitListId)) + .get(); + if (!list || list.projectId !== input.projectId) { + throw new Error("Circuit list does not belong to project."); + } + const source = database + .select() + .from(circuitSections) + .where(eq(circuitSections.id, plan.expectedSectionId)) + .get(); + const target = database + .select() + .from(circuitSections) + .where(eq(circuitSections.id, plan.targetSectionId)) + .get(); + const expectedBmk = parseGroupedEquipmentIdentifier( + plan.expectedEquipmentIdentifier + ); + const targetBmk = parseGroupedEquipmentIdentifier( + plan.targetEquipmentIdentifier + ); + if ( + !source || + !target || + source.circuitListId !== plan.circuitListId || + target.circuitListId !== plan.circuitListId || + source.category === null || + source.category !== target.category || + source.category !== expectedBmk?.category || + source.groupNumber !== expectedBmk.groupNumber || + target.category !== targetBmk?.category || + target.groupNumber !== targetBmk.groupNumber + ) { + throw new Error( + "Circuit move requires matching source and target groups." + ); + } + const updated = database + .update(circuits) + .set({ + sectionId: plan.targetSectionId, + equipmentIdentifier: plan.targetEquipmentIdentifier, + sortOrder: plan.targetSortOrder, + }) + .where( + and( + eq(circuits.id, plan.circuitId), + eq(circuits.circuitListId, plan.circuitListId), + eq(circuits.sectionId, plan.expectedSectionId), + eq( + circuits.equipmentIdentifier, + plan.expectedEquipmentIdentifier + ), + eq(circuits.sortOrder, plan.expectedSortOrder) + ) + ) + .run(); + if (updated.changes !== 1) { + throw new Error("Circuit changed before group move."); + } + return createCircuitGroupMoveProjectCommand( + invertCircuitGroupMovePlan(plan) + ); + } +} diff --git a/src/domain/models/circuit-group-move-project-command.model.ts b/src/domain/models/circuit-group-move-project-command.model.ts new file mode 100644 index 0000000..ef1f774 --- /dev/null +++ b/src/domain/models/circuit-group-move-project-command.model.ts @@ -0,0 +1,92 @@ +import { parseGroupedEquipmentIdentifier } from "../services/circuit-group-numbering.js"; +import type { CircuitGroupMovePlan } from "../services/circuit-group-move-planning.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitGroupMoveCommandType = "circuit.move-group" as const; +export const circuitGroupMoveCommandSchemaVersion = 1 as const; + +export interface CircuitGroupMoveProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupMoveCommandSchemaVersion; + type: typeof circuitGroupMoveCommandType; +} + +export function createCircuitGroupMoveProjectCommand( + plan: CircuitGroupMovePlan +): CircuitGroupMoveProjectCommand { + const command: CircuitGroupMoveProjectCommand = { + schemaVersion: circuitGroupMoveCommandSchemaVersion, + type: circuitGroupMoveCommandType, + payload: plan, + }; + assertCircuitGroupMoveProjectCommand(command); + return command; +} + +export function assertCircuitGroupMoveProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupMoveProjectCommand { + if ( + command.schemaVersion !== circuitGroupMoveCommandSchemaVersion || + command.type !== circuitGroupMoveCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 8 + ) { + throw new Error("Unsupported circuit group-move command."); + } + for (const field of [ + "circuitId", + "circuitListId", + "expectedSectionId", + "targetSectionId", + "expectedEquipmentIdentifier", + "targetEquipmentIdentifier", + ] as const) { + if ( + typeof command.payload[field] !== "string" || + !command.payload[field].trim() + ) { + throw new Error(`Circuit group-move ${field} is invalid.`); + } + } + if ( + command.payload.expectedSectionId === command.payload.targetSectionId || + !Number.isFinite(command.payload.expectedSortOrder) || + !Number.isFinite(command.payload.targetSortOrder) + ) { + throw new Error("Circuit group-move positions are invalid."); + } + const expected = parseGroupedEquipmentIdentifier( + command.payload.expectedEquipmentIdentifier as string + ); + const target = parseGroupedEquipmentIdentifier( + command.payload.targetEquipmentIdentifier as string + ); + if ( + expected?.kind !== "circuit" || + target?.kind !== "circuit" || + expected.category !== target.category || + expected.groupNumber === target.groupNumber + ) { + throw new Error("Circuit group-move BMKs are incompatible."); + } +} + +export function invertCircuitGroupMovePlan( + plan: CircuitGroupMovePlan +): CircuitGroupMovePlan { + return { + circuitId: plan.circuitId, + circuitListId: plan.circuitListId, + expectedSectionId: plan.targetSectionId, + targetSectionId: plan.expectedSectionId, + expectedEquipmentIdentifier: plan.targetEquipmentIdentifier, + targetEquipmentIdentifier: plan.expectedEquipmentIdentifier, + expectedSortOrder: plan.targetSortOrder, + targetSortOrder: plan.expectedSortOrder, + }; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-group-move-project-command.store.ts b/src/domain/ports/circuit-group-move-project-command.store.ts new file mode 100644 index 0000000..704d82b --- /dev/null +++ b/src/domain/ports/circuit-group-move-project-command.store.ts @@ -0,0 +1,22 @@ +import type { CircuitGroupMoveProjectCommand } from "../models/circuit-group-move-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitGroupMoveCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitGroupMoveProjectCommand; +} + +export interface CircuitGroupMoveProjectCommandStore { + execute(input: ExecuteCircuitGroupMoveCommandInput): { + revision: AppendedProjectRevision; + inverse: CircuitGroupMoveProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 342fadd..620a0b3 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -100,6 +100,7 @@ import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit 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 { CircuitGroupRenumberProjectCommandStore } from "../ports/circuit-group-renumber-project-command.store.js"; +import type { CircuitGroupMoveProjectCommandStore } from "../ports/circuit-group-move-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 { @@ -126,6 +127,10 @@ import { assertCircuitGroupRenumberProjectCommand, circuitGroupRenumberCommandType, } from "../models/circuit-group-renumber-project-command.model.js"; +import { + assertCircuitGroupMoveProjectCommand, + circuitGroupMoveCommandType, +} from "../models/circuit-group-move-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"; @@ -163,6 +168,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore, private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore, private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore, + private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -386,6 +392,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitGroupMoveCommandType: { + assertCircuitGroupMoveProjectCommand(input.command); + return this.circuitGroupMoveStore.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 9112aa2..344ac5d 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -41,6 +41,7 @@ const commandTypeLabels: Record = { "circuit-group.update": "Stromkreisgruppe bearbeitet", "circuit-group.reorder": "Stromkreisgruppen sortiert", "circuit-group.renumber": "Stromkreisgruppen neu nummeriert", + "circuit.move-group": "Stromkreis in andere Gruppe verschoben", "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 eaa0ba7..17b7b0d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -8,6 +8,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../../db/reposit import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; 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 { 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"; @@ -32,6 +33,8 @@ export const circuitGroupStructureProjectCommandStore = new CircuitGroupStructureProjectCommandRepository(db); export const circuitGroupRenumberProjectCommandStore = new CircuitGroupRenumberProjectCommandRepository(db); +export const circuitGroupMoveProjectCommandStore = + new CircuitGroupMoveProjectCommandRepository(db); export const distributionBoardStructureProjectCommandStore = new DistributionBoardStructureProjectCommandRepository(db); export const distributionBoardComponentStructureProjectCommandStore = @@ -71,5 +74,6 @@ export const projectCommandService = new ProjectCommandService( distributionBoardComponentStructureProjectCommandStore, circuitGroupStructureProjectCommandStore, circuitGroupRenumberProjectCommandStore, + circuitGroupMoveProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-group-move-project-command.repository.test.ts b/tests/circuit-group-move-project-command.repository.test.ts new file mode 100644 index 0000000..90fe2dc --- /dev/null +++ b/tests/circuit-group-move-project-command.repository.test.ts @@ -0,0 +1,305 @@ +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 { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-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 { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.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 { 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 source = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.category, "lighting")) + .get()!; + context.db + .insert(circuitSections) + .values({ + id: "lighting-move-target", + circuitListId: list.id, + key: "lighting_2", + displayName: "Beleuchtung 2", + prefix: "-1F2.", + sortOrder: 40, + category: "lighting", + groupNumber: 2, + }) + .run(); + context.db + .insert(circuits) + .values([ + { + id: "circuit-move", + circuitListId: list.id, + sectionId: source.id, + equipmentIdentifier: "-1F1.7", + displayName: "Zu verschieben", + sortOrder: 20, + }, + { + id: "circuit-target-existing", + circuitListId: list.id, + sectionId: "lighting-move-target", + equipmentIdentifier: "-1F2.4", + displayName: "Bestand", + sortOrder: 10, + }, + ]) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-move", + circuitId: "circuit-move", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 2, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + context.db + .insert(circuitProtectionDevices) + .values({ + circuitId: "circuit-move", + type: "LS", + ratedCurrentA: 10, + tripCharacteristic: "B", + }) + .run(); + return context; +} + +function createMoveCommand(context: DatabaseContext) { + const circuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-move")) + .get()!; + const source = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, circuit.sectionId)) + .get()!; + const target = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, "lighting-move-target")) + .get()!; + return createCircuitGroupMoveProjectCommand( + createCircuitGroupMovePlan({ + circuit, + sourceGroup: { + id: source.id, + category: source.category!, + groupNumber: source.groupNumber!, + prefix: source.prefix, + }, + targetGroup: { + id: target.id, + category: target.category!, + groupNumber: target.groupNumber!, + prefix: target.prefix, + }, + targetCircuitEquipmentIdentifiers: context.db + .select({ value: circuits.equipmentIdentifier }) + .from(circuits) + .where(eq(circuits.sectionId, target.id)) + .all() + .map(({ value }) => value), + targetSortOrder: 30, + }) + ); +} + +describe("circuit group-move project command", () => { + it("moves a complete circuit while preserving rows and protection through undo/redo", () => { + const context = createTestDatabase(); + try { + const command = createMoveCommand(context); + const repository = + new CircuitGroupMoveProjectCommandRepository(context.db); + const moved = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual( + context.db + .select({ + sectionId: circuits.sectionId, + equipmentIdentifier: circuits.equipmentIdentifier, + sortOrder: circuits.sortOrder, + }) + .from(circuits) + .where(eq(circuits.id, "circuit-move")) + .get(), + { + sectionId: "lighting-move-target", + equipmentIdentifier: "-1F2.5", + sortOrder: 30, + } + ); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-move")) + .get()?.circuitId, + "circuit-move" + ); + assert.equal( + context.db + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, "circuit-move")) + .get()?.ratedCurrentA, + 10 + ); + + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: moved.inverse, + }); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-move")) + .get()?.equipmentIdentifier, + "-1F1.7" + ); + + 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, "circuit-move")) + .get()?.equipmentIdentifier, + "-1F2.5" + ); + } finally { + context.close(); + } + }); + + it("rejects stale state without moving the circuit", () => { + const context = createTestDatabase(); + try { + const command = createMoveCommand(context); + context.db + .update(circuits) + .set({ sortOrder: 99 }) + .where(eq(circuits.id, "circuit-move")) + .run(); + assert.throws( + () => + new CircuitGroupMoveProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }), + /changed before group move/ + ); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-move")) + .get()?.sectionId, + command.payload.expectedSectionId + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); + + it("rolls back the circuit move when history persistence fails", () => { + const context = createTestDatabase(); + try { + const command = createMoveCommand(context); + context.sqlite.exec(` + CREATE TRIGGER fail_circuit_group_move_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced circuit group move history failure'); + END; + `); + assert.throws( + () => + new CircuitGroupMoveProjectCommandRepository(context.db).execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }), + /forced circuit group move history failure/ + ); + assert.deepEqual( + context.db + .select({ + sectionId: circuits.sectionId, + equipmentIdentifier: circuits.equipmentIdentifier, + sortOrder: circuits.sortOrder, + }) + .from(circuits) + .where(eq(circuits.id, "circuit-move")) + .get(), + { + sectionId: command.payload.expectedSectionId, + equipmentIdentifier: "-1F1.7", + sortOrder: 20, + } + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 41c4648..2cc8129 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -12,6 +12,7 @@ import "./circuit-group-structure-project-command.repository.test.js"; import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js"; 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"; describe("circuit group numbering", () => { it("formats the agreed identifiers including the leading hyphen", () => { diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index ea28056..6943687 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 { 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 { 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"; @@ -56,6 +57,8 @@ import { } from "../src/domain/models/circuit-group-structure-project-command.model.js"; import { createCircuitGroupRenumberProjectCommand } from "../src/domain/models/circuit-group-renumber-project-command.model.js"; 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 { createDistributionBoardInsertProjectCommand, createDistributionBoardStructureSnapshot, @@ -142,6 +145,7 @@ function createService(context: DatabaseContext) { ), new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db), + new CircuitGroupMoveProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -1370,6 +1374,79 @@ describe("project command service", () => { } }); + it("dispatches same-category circuit group moves", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const source = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.category, "three_phase")) + .get()!; + const target = { + id: "group-move-service-target", + circuitListId: source.circuitListId, + key: "three_phase_2", + displayName: "3-phasig 2", + prefix: "-3F2.", + sortOrder: 40, + category: "three_phase" as const, + groupNumber: 2, + }; + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitGroupInsertProjectCommand(target), + }); + context.db + .insert(circuits) + .values({ + id: "circuit-group-move-service", + circuitListId: source.circuitListId, + sectionId: source.id, + equipmentIdentifier: "-3F1.1", + displayName: "Drehstrom", + sortOrder: 10, + }) + .run(); + const result = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createCircuitGroupMoveProjectCommand( + createCircuitGroupMovePlan({ + circuit: { + id: "circuit-group-move-service", + circuitListId: source.circuitListId, + sectionId: source.id, + equipmentIdentifier: "-3F1.1", + sortOrder: 10, + }, + sourceGroup: { + id: source.id, + category: "three_phase", + groupNumber: 1, + prefix: "-3F1.", + }, + targetGroup: target, + targetCircuitEquipmentIdentifiers: [], + targetSortOrder: 20, + }) + ), + }); + assert.equal(result.history.currentRevision, 2); + assert.equal( + context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-group-move-service")) + .get()?.equipmentIdentifier, + "-3F2.1" + ); + } 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 1e31cbc..5900471 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 { 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 { 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"; @@ -199,6 +200,7 @@ function createService(context: DatabaseContext) { ), new CircuitGroupStructureProjectCommandRepository(context.db), new CircuitGroupRenumberProjectCommandRepository(context.db), + new CircuitGroupMoveProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 84aee3a..3cb2c25 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -169,6 +169,12 @@ describe("project version history presentation", () => { ), "Stromkreisgruppen neu nummeriert" ); + assert.equal( + getProjectRevisionDescription( + revision(13, { commandType: "circuit.move-group" }) + ), + "Stromkreis in andere Gruppe verschoben" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),