From e702712bd8bf075a4f97984af8a8743ec66b1b2c Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 30 Jul 2026 19:49:27 +0200 Subject: [PATCH] Add circuit group reordering --- AGENTS.md | 3 + docs/current-architecture.md | 9 +- docs/spec/08-current-product-backlog.md | 2 +- ...-board-components-and-protection-groups.md | 13 +- ...up-structure-project-command.repository.ts | 72 ++++++++++ ...t-group-structure-project-command.model.ts | 72 +++++++++- .../services/project-command.service.ts | 9 ++ src/frontend/utils/project-version-history.ts | 1 + ...ructure-project-command.repository.test.ts | 134 ++++++++++++++++++ tests/project-command.service.test.ts | 21 +++ tests/project-version-history.test.ts | 6 + 11 files changed, 335 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76a6289..39bc5b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -314,6 +314,9 @@ Empty circuit-group creation, display-name updates and deletion use 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. +`circuit-group.reorder` requires a complete expected/target sort assignment for +every group in one circuit list. It changes only sort positions and never +renumbers groups, prefixes or circuits. 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 03449e2..0803e05 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -305,6 +305,11 @@ 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. +`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten- +Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position +enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern, +Präfixe und Stromkreis-BMKs bleiben stabil. Die gesamte Sortierung bildet eine +Revision und einen Undo/Redo-Schritt. `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 @@ -377,8 +382,8 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen - sind integriert; Gruppensortierung, befüllte Unterbäume und UI folgen in - abgegrenzten Arbeitspaketen. + sowie vollständige Gruppensortierung sind integriert; 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 d9f0f40..6d3b4d6 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -24,7 +24,7 @@ requirements and intended sequencing, not proof of implementation. protection and auxiliary components. - [x] Phase C2a2: component update and reorder commands. - [x] Phase C2b1: empty-group insert/update/delete commands. -- [ ] Phase C2b2: complete group reorder command. +- [x] 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 ae02420..f5bed5e 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -593,9 +593,7 @@ Acceptance: ### C. Persistent Commands and Board Defaults -Status: In progress. New-board defaults C1, component management C2a and -empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains -pending. +Status: Complete. - extend `distribution-board.insert` with fixed components and three groups - implement component and group CRUD commands @@ -647,6 +645,15 @@ Implemented in C2b1: - project/list ownership, stale state, Undo/Redo and late rollback are covered at the shared transaction boundary +Implemented in C2b2: + +- `circuit-group.reorder` requires every group in the circuit list with its + exact expected and target `sortOrder` +- sorting changes no group number, prefix or circuit equipment identifier +- incomplete or stale assignments are rejected before history is recorded +- the complete reorder is one project revision and one persistent Undo/Redo + step; late failures roll back every position + 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 index 6f4b264..a11d612 100644 --- a/src/db/repositories/circuit-group-structure-project-command.repository.ts +++ b/src/db/repositories/circuit-group-structure-project-command.repository.ts @@ -2,13 +2,17 @@ import { and, eq } from "drizzle-orm"; import { assertCircuitGroupDeleteProjectCommand, assertCircuitGroupInsertProjectCommand, + assertCircuitGroupReorderProjectCommand, assertCircuitGroupUpdateProjectCommand, circuitGroupDeleteCommandType, circuitGroupInsertCommandType, + circuitGroupReorderCommandType, circuitGroupUpdateCommandType, createCircuitGroupDeleteProjectCommand, createCircuitGroupInsertProjectCommand, + createCircuitGroupReorderProjectCommand, createCircuitGroupUpdateProjectCommand, + type CircuitGroupReorderAssignment, type CircuitGroupSnapshot, type CircuitGroupStructureProjectCommand, } from "../../domain/models/circuit-group-structure-project-command.model.js"; @@ -68,6 +72,19 @@ export class CircuitGroupStructureProjectCommandRepository command.payload.expected ); } + if (command.type === circuitGroupReorderCommandType) { + assertCircuitGroupReorderProjectCommand(command); + const inverseAssignments = this.reorder( + database, + projectId, + command.payload.circuitListId, + command.payload.assignments + ); + return createCircuitGroupReorderProjectCommand( + command.payload.circuitListId, + inverseAssignments + ); + } throw new Error("Unsupported circuit-group structure command."); } @@ -168,6 +185,61 @@ export class CircuitGroupStructureProjectCommandRepository } } + private reorder( + database: AppDatabase, + projectId: string, + circuitListId: string, + assignments: CircuitGroupReorderAssignment[] + ) { + this.assertListOwnership(database, projectId, circuitListId); + const persisted = database + .select({ + id: circuitSections.id, + sortOrder: circuitSections.sortOrder, + }) + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitListId)) + .all(); + const persistedById = new Map( + persisted.map((group) => [group.id, group]) + ); + if ( + persisted.length !== assignments.length || + assignments.some((assignment) => { + const group = persistedById.get(assignment.groupId); + return !group || group.sortOrder !== assignment.expectedSortOrder; + }) + ) { + throw new Error( + "Circuit-group reorder must include every unchanged group in the list." + ); + } + for (const assignment of assignments) { + if (assignment.expectedSortOrder === assignment.targetSortOrder) { + continue; + } + const updated = database + .update(circuitSections) + .set({ sortOrder: assignment.targetSortOrder }) + .where( + and( + eq(circuitSections.id, assignment.groupId), + eq(circuitSections.circuitListId, circuitListId), + eq(circuitSections.sortOrder, assignment.expectedSortOrder) + ) + ) + .run(); + if (updated.changes !== 1) { + throw new Error("Circuit group changed during reorder."); + } + } + return assignments.map((assignment) => ({ + groupId: assignment.groupId, + expectedSortOrder: assignment.targetSortOrder, + targetSortOrder: assignment.expectedSortOrder, + })); + } + private getCurrent(database: AppDatabase, id: string) { return database .select() diff --git a/src/domain/models/circuit-group-structure-project-command.model.ts b/src/domain/models/circuit-group-structure-project-command.model.ts index 4430c80..22918a0 100644 --- a/src/domain/models/circuit-group-structure-project-command.model.ts +++ b/src/domain/models/circuit-group-structure-project-command.model.ts @@ -8,6 +8,7 @@ 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 circuitGroupReorderCommandType = "circuit-group.reorder" as const; export const circuitGroupStructureCommandSchemaVersion = 1 as const; export interface CircuitGroupSnapshot { @@ -30,6 +31,17 @@ interface UpdatePayload { target: CircuitGroupSnapshot; } +export interface CircuitGroupReorderAssignment { + groupId: string; + expectedSortOrder: number; + targetSortOrder: number; +} + +interface ReorderPayload { + circuitListId: string; + assignments: CircuitGroupReorderAssignment[]; +} + export interface CircuitGroupInsertProjectCommand extends SerializedProjectCommand { schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; @@ -48,10 +60,17 @@ export interface CircuitGroupUpdateProjectCommand type: typeof circuitGroupUpdateCommandType; } +export interface CircuitGroupReorderProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitGroupStructureCommandSchemaVersion; + type: typeof circuitGroupReorderCommandType; +} + export type CircuitGroupStructureProjectCommand = | CircuitGroupInsertProjectCommand | CircuitGroupDeleteProjectCommand - | CircuitGroupUpdateProjectCommand; + | CircuitGroupUpdateProjectCommand + | CircuitGroupReorderProjectCommand; export function createCircuitGroupInsertProjectCommand( snapshot: CircuitGroupSnapshot @@ -90,6 +109,19 @@ export function createCircuitGroupUpdateProjectCommand( return command; } +export function createCircuitGroupReorderProjectCommand( + circuitListId: string, + assignments: CircuitGroupReorderAssignment[] +): CircuitGroupReorderProjectCommand { + const command: CircuitGroupReorderProjectCommand = { + schemaVersion: circuitGroupStructureCommandSchemaVersion, + type: circuitGroupReorderCommandType, + payload: { circuitListId, assignments }, + }; + assertCircuitGroupReorderProjectCommand(command); + return command; +} + export function assertCircuitGroupInsertProjectCommand( command: SerializedProjectCommand ): asserts command is CircuitGroupInsertProjectCommand { @@ -132,6 +164,44 @@ export function assertCircuitGroupUpdateProjectCommand( } } +export function assertCircuitGroupReorderProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitGroupReorderProjectCommand { + if ( + command.schemaVersion !== circuitGroupStructureCommandSchemaVersion || + command.type !== circuitGroupReorderCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 || + typeof command.payload.circuitListId !== "string" || + !command.payload.circuitListId.trim() || + !Array.isArray(command.payload.assignments) || + command.payload.assignments.length === 0 + ) { + throw new Error("Unsupported circuit-group reorder command."); + } + const groupIds = new Set(); + let hasChange = false; + for (const assignment of command.payload.assignments) { + if ( + !isPlainObject(assignment) || + typeof assignment.groupId !== "string" || + !assignment.groupId.trim() || + !Number.isFinite(assignment.expectedSortOrder) || + !Number.isFinite(assignment.targetSortOrder) || + groupIds.has(assignment.groupId) + ) { + throw new Error( + "Circuit-group reorder contains an invalid or duplicate assignment." + ); + } + hasChange ||= assignment.expectedSortOrder !== assignment.targetSortOrder; + groupIds.add(assignment.groupId); + } + if (!hasChange) { + throw new Error("Circuit-group reorder must change at least one position."); + } +} + function assertSnapshotCommand( command: SerializedProjectCommand, type: diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 6b09bbd..bd4ccba 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -114,9 +114,11 @@ import type { import { assertCircuitGroupDeleteProjectCommand, assertCircuitGroupInsertProjectCommand, + assertCircuitGroupReorderProjectCommand, assertCircuitGroupUpdateProjectCommand, circuitGroupDeleteCommandType, circuitGroupInsertCommandType, + circuitGroupReorderCommandType, circuitGroupUpdateCommandType, } from "../models/circuit-group-structure-project-command.model.js"; import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js"; @@ -364,6 +366,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitGroupReorderCommandType: { + assertCircuitGroupReorderProjectCommand(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 19b9727..fa49156 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -39,6 +39,7 @@ const commandTypeLabels: Record = { "circuit-group.insert": "Stromkreisgruppe angelegt", "circuit-group.delete": "Stromkreisgruppe entfernt", "circuit-group.update": "Stromkreisgruppe bearbeitet", + "circuit-group.reorder": "Stromkreisgruppen sortiert", "project-floor.insert": "Geschoss angelegt", "project-floor.delete": "Geschoss entfernt", "project-room.insert": "Raum angelegt", diff --git a/tests/circuit-group-structure-project-command.repository.test.ts b/tests/circuit-group-structure-project-command.repository.test.ts index 3b501d7..0d1c231 100644 --- a/tests/circuit-group-structure-project-command.repository.test.ts +++ b/tests/circuit-group-structure-project-command.repository.test.ts @@ -17,6 +17,7 @@ import { projects } from "../src/db/schema/projects.js"; import { createCircuitGroupDeleteProjectCommand, createCircuitGroupInsertProjectCommand, + createCircuitGroupReorderProjectCommand, createCircuitGroupUpdateProjectCommand, type CircuitGroupSnapshot, } from "../src/domain/models/circuit-group-structure-project-command.model.js"; @@ -293,4 +294,137 @@ describe("circuit-group structure project command", () => { context.close(); } }); + + it("reorders every group atomically without changing group identity", () => { + const context = createTestDatabase(); + try { + const circuitListId = getProjectList(context, "project-1").id; + const groups = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitListId)) + .all(); + const expectedPrefixes = new Map( + groups.map((group) => [group.id, group.prefix]) + ); + const assignments = groups.map((group, index) => ({ + groupId: group.id, + expectedSortOrder: group.sortOrder, + targetSortOrder: (groups.length - index) * 10, + })); + const repository = + new CircuitGroupStructureProjectCommandRepository(context.db); + const reordered = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupReorderProjectCommand( + circuitListId, + assignments + ), + }); + for (const assignment of assignments) { + const group = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, assignment.groupId)) + .get(); + assert.equal(group?.sortOrder, assignment.targetSortOrder); + assert.equal( + group?.prefix, + expectedPrefixes.get(assignment.groupId) + ); + } + + repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: new ProjectHistoryRepository( + context.db + ).getNextCommand("project-1", "undo")?.changeSetId, + command: reordered.inverse, + }); + for (const assignment of assignments) { + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, assignment.groupId)) + .get()?.sortOrder, + assignment.expectedSortOrder + ); + } + } finally { + context.close(); + } + }); + + it("rejects incomplete reorders and rolls back late failures", () => { + const context = createTestDatabase(); + try { + const circuitListId = getProjectList(context, "project-1").id; + const groups = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitListId)) + .all(); + const assignments = groups.map((group, index) => ({ + groupId: group.id, + expectedSortOrder: group.sortOrder, + targetSortOrder: (groups.length - index) * 10, + })); + const repository = + new CircuitGroupStructureProjectCommandRepository(context.db); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupReorderProjectCommand( + circuitListId, + assignments.slice(1) + ), + }), + /include every unchanged group/ + ); + context.sqlite.exec(` + CREATE TRIGGER fail_group_reorder_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced group reorder history failure'); + END; + `); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitGroupReorderProjectCommand( + circuitListId, + assignments + ), + }), + /forced group reorder history failure/ + ); + for (const assignment of assignments) { + assert.equal( + context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.id, assignment.groupId)) + .get()?.sortOrder, + assignment.expectedSortOrder + ); + } + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); }); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 91896fe..957c4ca 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -50,6 +50,7 @@ import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createCircuitGroupInsertProjectCommand, + createCircuitGroupReorderProjectCommand, createCircuitGroupUpdateProjectCommand, } from "../src/domain/models/circuit-group-structure-project-command.model.js"; import { @@ -1270,6 +1271,26 @@ describe("project command service", () => { .get()?.displayName, expected.displayName ); + + const groups = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, circuitListId)) + .all(); + const reorderAssignments = groups.map((group, index) => ({ + groupId: group.id, + expectedSortOrder: group.sortOrder, + targetSortOrder: (groups.length - index) * 10, + })); + const reordered = createService(context).executeUser({ + projectId: "project-1", + expectedRevision: 3, + command: createCircuitGroupReorderProjectCommand( + circuitListId, + reorderAssignments + ), + }); + assert.equal(reordered.history.currentRevision, 4); } finally { context.close(); } diff --git a/tests/project-version-history.test.ts b/tests/project-version-history.test.ts index 2d9891f..4f87920 100644 --- a/tests/project-version-history.test.ts +++ b/tests/project-version-history.test.ts @@ -157,6 +157,12 @@ describe("project version history presentation", () => { ), "Stromkreisgruppe bearbeitet" ); + assert.equal( + getProjectRevisionDescription( + revision(11, { commandType: "circuit-group.reorder" }) + ), + "Stromkreisgruppen sortiert" + ); assert.equal(getProjectSnapshotKindLabel("named"), "Benannt"); assert.equal( getProjectSnapshotKindLabel("automatic"),