diff --git a/AGENTS.md b/AGENTS.md index 1ad06b0..6d640a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,7 +228,9 @@ Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and the UI cutover remain future work. CircuitDeviceRow moves between existing circuits and moves that create one new placeholder target circuit are also persisted. The latter stores the complete empty target snapshot so undo can -restore the rows and remove only the unchanged generated circuit. +restore the rows and remove only the unchanged generated circuit. Complete +in-section Circuit reorders are persisted separately and change sort positions +without changing equipment identifiers. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 9066816..daf16eb 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -27,14 +27,14 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, `circuit-device-row.delete` and `circuit-device-row.move` plus -`circuit-device-row.move-with-new-circuit`, all with schema version `1`. Other -command types are rejected. Insert commands contain the complete entity or -circuit block with stable ids; delete commands include the expected parent -identity. Circuit snapshots contain zero, one or multiple complete device -rows. Move commands contain each row's expected and target circuit plus its -exact expected and target sort order. This makes deletion and moves undoable -without generating replacement identities or renumbering circuits. The editor -does not consume these endpoints yet. +`circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all +with schema version `1`. Other command types are rejected. Insert commands +contain the complete entity or circuit block with stable ids; delete commands +include the expected parent identity. Circuit snapshots contain zero, one or +multiple complete device rows. Move commands contain each row's expected and +target circuit plus its exact expected and target sort order. This makes +deletion and moves undoable without generating replacement identities or +renumbering circuits. The editor does not consume these endpoints yet. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -43,6 +43,11 @@ rows into it. Its inverse restores every row to its exact prior position and deletes the generated circuit only if its fields and complete row set still match the recorded state. +`circuit.reorder-section` requires one assignment for every circuit currently +in the section. Each assignment records the expected and target `sortOrder`. +The command and its inverse change no circuit field other than `sortOrder`; +equipment identifiers and complete device-row blocks remain unchanged. + Example: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index b5a8b29..07f9041 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -95,9 +95,12 @@ Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen -Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung, -Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch -als persistente Commands modelliert werden. +Felder und vollständiger Zeilenbestand unverändert sind. +`circuit.reorder-section` speichert die erwartete und neue Sortierposition +jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo +ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs +bleiben unverändert. Neunummerierung und Synchronisierung müssen vor der +Frontend-Umstellung noch als persistente Commands modelliert werden. ## Projektgeräte diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 11a18ca..e8c4500 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -186,13 +186,16 @@ Completed foundation: empty target-circuit snapshot; forward creation and all row moves share one transaction, while undo restores the source positions and deletes only an unchanged generated target +- `circuit.reorder-section` requires the complete section circuit set and + stores exact expected/target sort positions; its inverse changes no + equipment identifier and never splits a circuit block Remaining constraints before completing persistent history: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move slices; the generic versioned command envelope is - complete + field/insert/delete/move plus section-reorder slices; the generic versioned + command envelope is complete - route the remaining structural, synchronization and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 52bc9d4..4b7215b 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -401,6 +401,8 @@ Implemented foundation: - placeholder-target moves persist one complete generated circuit identity and all row positions atomically; undo restores every source and removes only an unchanged generated circuit, without renumbering +- complete section reorders persist every expected and target circuit + `sortOrder`; undo/redo changes neither equipment identifiers nor device rows - revision metadata, change-set payloads and the project counter are committed in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index d62a7fe..8380717 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-section-reorder-project-command.repository.ts b/src/db/repositories/circuit-section-reorder-project-command.repository.ts new file mode 100644 index 0000000..54ec6c8 --- /dev/null +++ b/src/db/repositories/circuit-section-reorder-project-command.repository.ts @@ -0,0 +1,135 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitSectionReorderProjectCommand, + createCircuitSectionReorderProjectCommand, +} from "../../domain/models/circuit-section-reorder-project-command.model.js"; +import type { + CircuitSectionReorderProjectCommandStore, + ExecuteCircuitSectionReorderCommandInput, +} from "../../domain/ports/circuit-section-reorder-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 { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class CircuitSectionReorderProjectCommandRepository + implements CircuitSectionReorderProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitSectionReorderCommandInput) { + assertCircuitSectionReorderProjectCommand(input.command); + + return this.database.transaction((tx) => { + const section = tx + .select({ + id: circuitSections.id, + circuitListId: circuitSections.circuitListId, + projectId: circuitLists.projectId, + }) + .from(circuitSections) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuitSections.circuitListId) + ) + .where(eq(circuitSections.id, input.command.payload.sectionId)) + .get(); + if (!section || section.projectId !== input.projectId) { + throw new Error( + "Circuit section does not belong to project." + ); + } + + const persistedCircuits = tx + .select({ + id: circuits.id, + circuitListId: circuits.circuitListId, + sortOrder: circuits.sortOrder, + }) + .from(circuits) + .where(eq(circuits.sectionId, section.id)) + .all(); + const assignments = input.command.payload.assignments; + if ( + persistedCircuits.length !== assignments.length || + persistedCircuits.some( + (circuit) => + circuit.circuitListId !== section.circuitListId + ) + ) { + throw new Error( + "Circuit reorder must include every circuit in the section." + ); + } + const circuitsById = new Map( + persistedCircuits.map((circuit) => [circuit.id, circuit]) + ); + for (const assignment of assignments) { + const circuit = circuitsById.get(assignment.circuitId); + if ( + !circuit || + circuit.sortOrder !== assignment.expectedSortOrder + ) { + throw new Error( + "Circuit changed before section reorder execution." + ); + } + } + + const inverse = createCircuitSectionReorderProjectCommand( + section.id, + assignments.map((assignment) => ({ + circuitId: assignment.circuitId, + expectedSortOrder: assignment.targetSortOrder, + targetSortOrder: assignment.expectedSortOrder, + })) + ); + + for (const assignment of assignments) { + if ( + assignment.expectedSortOrder === assignment.targetSortOrder + ) { + continue; + } + const updated = tx + .update(circuits) + .set({ sortOrder: assignment.targetSortOrder }) + .where( + and( + eq(circuits.id, assignment.circuitId), + eq(circuits.sectionId, section.id), + eq( + circuits.sortOrder, + assignment.expectedSortOrder + ) + ) + ) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Circuit changed during section reorder execution." + ); + } + } + + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } +} diff --git a/src/domain/models/circuit-section-reorder-project-command.model.ts b/src/domain/models/circuit-section-reorder-project-command.model.ts new file mode 100644 index 0000000..e0f0a0f --- /dev/null +++ b/src/domain/models/circuit-section-reorder-project-command.model.ts @@ -0,0 +1,115 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitSectionReorderCommandType = + "circuit.reorder-section" as const; +export const circuitSectionReorderCommandSchemaVersion = 1 as const; + +export interface CircuitSectionReorderAssignment { + circuitId: string; + expectedSortOrder: number; + targetSortOrder: number; +} + +export interface CircuitSectionReorderCommandPayload { + sectionId: string; + assignments: CircuitSectionReorderAssignment[]; +} + +export interface CircuitSectionReorderProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitSectionReorderCommandSchemaVersion; + type: typeof circuitSectionReorderCommandType; +} + +export function createCircuitSectionReorderProjectCommand( + sectionId: string, + assignments: CircuitSectionReorderAssignment[] +): CircuitSectionReorderProjectCommand { + const command: CircuitSectionReorderProjectCommand = { + schemaVersion: circuitSectionReorderCommandSchemaVersion, + type: circuitSectionReorderCommandType, + payload: { sectionId, assignments }, + }; + assertCircuitSectionReorderProjectCommand(command); + return command; +} + +export function assertCircuitSectionReorderProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitSectionReorderProjectCommand { + if ( + command.schemaVersion !== circuitSectionReorderCommandSchemaVersion || + command.type !== circuitSectionReorderCommandType + ) { + throw new Error("Unsupported circuit section reorder command."); + } + if (!isPlainObject(command.payload)) { + throw new Error( + "Circuit section reorder payload must be an object." + ); + } + const { sectionId, assignments } = command.payload; + assertNonEmptyString(sectionId, "sectionId"); + if (!Array.isArray(assignments) || assignments.length === 0) { + throw new Error( + "Circuit section reorder command requires assignments." + ); + } + + const circuitIds = new Set(); + let hasChangedPosition = false; + for (const assignment of assignments) { + if (!isPlainObject(assignment)) { + throw new Error( + "Circuit section reorder command contains an invalid assignment." + ); + } + assertNonEmptyString(assignment.circuitId, "circuitId"); + assertFiniteNumber( + assignment.expectedSortOrder, + "expectedSortOrder" + ); + assertFiniteNumber( + assignment.targetSortOrder, + "targetSortOrder" + ); + if (circuitIds.has(assignment.circuitId)) { + throw new Error( + "Circuit section reorder command contains duplicate circuit ids." + ); + } + if ( + assignment.expectedSortOrder !== assignment.targetSortOrder + ) { + hasChangedPosition = true; + } + circuitIds.add(assignment.circuitId); + } + if (!hasChangedPosition) { + throw new Error( + "Circuit section reorder command must change at least one position." + ); + } +} + +function assertNonEmptyString( + value: unknown, + field: string +): asserts value is string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function assertFiniteNumber( + value: unknown, + field: string +): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${field} must be a finite number.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-section-reorder-project-command.store.ts b/src/domain/ports/circuit-section-reorder-project-command.store.ts new file mode 100644 index 0000000..6502182 --- /dev/null +++ b/src/domain/ports/circuit-section-reorder-project-command.store.ts @@ -0,0 +1,26 @@ +import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitSectionReorderCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitSectionReorderProjectCommand; +} + +export interface ExecutedCircuitSectionReorderCommand { + revision: AppendedProjectRevision; + inverse: CircuitSectionReorderProjectCommand; +} + +export interface CircuitSectionReorderProjectCommandStore { + execute( + input: ExecuteCircuitSectionReorderCommandInput + ): ExecutedCircuitSectionReorderCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 596c12a..1502c41 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -19,6 +19,10 @@ import { assertCircuitUpdateProjectCommand, circuitUpdateCommandType, } from "../models/circuit-project-command.model.js"; +import { + assertCircuitSectionReorderProjectCommand, + circuitSectionReorderCommandType, +} from "../models/circuit-section-reorder-project-command.model.js"; import { assertCircuitDeleteProjectCommand, assertCircuitInsertProjectCommand, @@ -33,6 +37,7 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; +import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js"; import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; import type { ExecuteProjectHistoryCommandInput, @@ -63,6 +68,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore, + private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -186,6 +192,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitSectionReorderCommandType: { + assertCircuitSectionReorderProjectCommand(input.command); + return this.circuitSectionReorderStore.execute({ + ...input, + command: input.command, + }).revision; + } default: throw new Error( `Unsupported project command type: ${input.command.type}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 56d1075..254c5e5 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -3,6 +3,7 @@ import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/ import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; +import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js"; @@ -16,6 +17,8 @@ export const circuitDeviceRowMoveProjectCommandStore = new CircuitDeviceRowMoveProjectCommandRepository(db); export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); +export const circuitSectionReorderProjectCommandStore = + new CircuitSectionReorderProjectCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, @@ -23,5 +26,6 @@ export const projectCommandService = new ProjectCommandService( circuitDeviceRowStructureProjectCommandStore, circuitDeviceRowMoveProjectCommandStore, circuitStructureProjectCommandStore, + circuitSectionReorderProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-section-reorder-project-command.repository.test.ts b/tests/circuit-section-reorder-project-command.repository.test.ts new file mode 100644 index 0000000..7cca002 --- /dev/null +++ b/tests/circuit-section-reorder-project-command.repository.test.ts @@ -0,0 +1,391 @@ +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 { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projects } from "../src/db/schema/projects.js"; +import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; + +interface TestFixture { + context: DatabaseContext; + sectionId: string; + foreignSectionId: string; +} + +function createTestDatabase(): TestFixture { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Other project" }, + ]) + .run(); + const boards = new DistributionBoardRepository(context.db); + const board = boards.createWithCircuitListAndDefaultSections( + "project-1", + "UV-01" + ); + const foreignBoard = boards.createWithCircuitListAndDefaultSections( + "project-2", + "UV-02" + ); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + const foreignSection = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, foreignBoard.id)) + .get(); + assert.ok(section); + assert.ok(foreignSection); + + context.db + .insert(circuits) + .values([ + { + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }, + { + id: "circuit-2", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F2", + sortOrder: 20, + }, + { + id: "circuit-3", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F3", + sortOrder: 30, + }, + { + id: "circuit-foreign", + circuitListId: foreignBoard.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + }, + ]) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }) + .run(); + + return { + context, + sectionId: section.id, + foreignSectionId: foreignSection.id, + }; +} + +function getCircuitState(context: DatabaseContext) { + return context.db + .select({ + id: circuits.id, + equipmentIdentifier: circuits.equipmentIdentifier, + sortOrder: circuits.sortOrder, + }) + .from(circuits) + .all() + .filter((circuit) => circuit.id !== "circuit-foreign") + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function createReorderCommand(sectionId: string) { + return createCircuitSectionReorderProjectCommand(sectionId, [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + { + circuitId: "circuit-2", + expectedSortOrder: 20, + targetSortOrder: 30, + }, + { + circuitId: "circuit-3", + expectedSortOrder: 30, + targetSortOrder: 10, + }, + ]); +} + +describe("circuit section reorder project-command repository", () => { + it("reorders a complete section and supports persisted undo and redo", () => { + const fixture = createTestDatabase(); + try { + const store = + new CircuitSectionReorderProjectCommandRepository( + fixture.context.db + ); + const command = createReorderCommand(fixture.sectionId); + const reordered = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual(getCircuitState(fixture.context), [ + { + id: "circuit-1", + equipmentIdentifier: "-1F1", + sortOrder: 20, + }, + { + id: "circuit-2", + equipmentIdentifier: "-1F2", + sortOrder: 30, + }, + { + id: "circuit-3", + equipmentIdentifier: "-1F3", + sortOrder: 10, + }, + ]); + assert.equal( + fixture.context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.circuitId, + "circuit-1" + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: + reordered.revision.changeSetId, + command: reordered.inverse, + }); + assert.deepEqual( + getCircuitState(fixture.context).map( + (circuit) => circuit.sortOrder + ), + [10, 20, 30] + ); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: + reordered.revision.changeSetId, + command, + }); + assert.deepEqual( + new ProjectHistoryRepository(fixture.context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: reordered.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + fixture.context.close(); + } + }); + + it("rejects incomplete, stale and foreign-section reorders", () => { + const fixture = createTestDatabase(); + try { + const store = + new CircuitSectionReorderProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitSectionReorderProjectCommand( + fixture.sectionId, + [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + { + circuitId: "circuit-2", + expectedSortOrder: 20, + targetSortOrder: 10, + }, + ] + ), + }), + /every circuit/ + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitSectionReorderProjectCommand( + fixture.sectionId, + [ + { + circuitId: "circuit-1", + expectedSortOrder: 999, + targetSortOrder: 20, + }, + { + circuitId: "circuit-2", + expectedSortOrder: 20, + targetSortOrder: 30, + }, + { + circuitId: "circuit-3", + expectedSortOrder: 30, + targetSortOrder: 10, + }, + ] + ), + }), + /changed before/ + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitSectionReorderProjectCommand( + fixture.foreignSectionId, + [ + { + circuitId: "circuit-foreign", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + ] + ), + }), + /does not belong to project/ + ); + assert.deepEqual( + getCircuitState(fixture.context).map( + (circuit) => circuit.sortOrder + ), + [10, 20, 30] + ); + assert.equal( + fixture.context.db.select().from(projectRevisions).all() + .length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back all sort positions for late history failures", () => { + const fixture = createTestDatabase(); + try { + fixture.context.sqlite.exec(` + CREATE TRIGGER fail_reorder_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced reorder history failure'); + END; + `); + const store = + new CircuitSectionReorderProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createReorderCommand(fixture.sectionId), + }), + /forced reorder history failure/ + ); + assert.deepEqual( + getCircuitState(fixture.context).map( + (circuit) => circuit.sortOrder + ), + [10, 20, 30] + ); + assert.equal( + fixture.context.db.select().from(projectRevisions).all() + .length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back the reorder for a stale project revision", () => { + const fixture = createTestDatabase(); + try { + const store = + new CircuitSectionReorderProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createReorderCommand(fixture.sectionId), + }), + /at revision 0, expected 1/ + ); + assert.deepEqual( + getCircuitState(fixture.context).map( + (circuit) => circuit.sortOrder + ), + [10, 20, 30] + ); + } finally { + fixture.context.close(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 7a37c63..e9c0454 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -29,6 +29,10 @@ import { createCircuitDeleteProjectCommand, createCircuitInsertProjectCommand, } from "../src/domain/models/circuit-structure-project-command.model.js"; +import { + assertCircuitSectionReorderProjectCommand, + createCircuitSectionReorderProjectCommand, +} from "../src/domain/models/circuit-section-reorder-project-command.model.js"; describe("serialized project commands", () => { it("round-trips a versioned command envelope", () => { @@ -519,3 +523,61 @@ describe("circuit structure project commands", () => { ); }); }); + +describe("circuit section reorder project commands", () => { + it("captures every expected and target sort position", () => { + const command = createCircuitSectionReorderProjectCommand( + "section-1", + [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + { + circuitId: "circuit-2", + expectedSortOrder: 20, + targetSortOrder: 10, + }, + ] + ); + assert.equal(command.payload.assignments.length, 2); + assert.doesNotThrow(() => + assertCircuitSectionReorderProjectCommand(command) + ); + }); + + it("rejects empty, duplicate and complete no-op assignments", () => { + assert.throws( + () => createCircuitSectionReorderProjectCommand("", []), + /sectionId/ + ); + assert.throws( + () => + createCircuitSectionReorderProjectCommand("section-1", [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + { + circuitId: "circuit-1", + expectedSortOrder: 20, + targetSortOrder: 10, + }, + ]), + /duplicate circuit ids/ + ); + assert.throws( + () => + createCircuitSectionReorderProjectCommand("section-1", [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 10, + }, + ]), + /change at least one position/ + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index f22c9f0..6a04b08 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -11,6 +11,7 @@ import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; @@ -29,6 +30,7 @@ import { } from "../src/domain/models/circuit-device-row-move-project-command.model.js"; import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; +import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; @@ -84,6 +86,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), + new CircuitSectionReorderProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -475,6 +478,91 @@ describe("project command service", () => { } }); + it("dispatches complete section reorders without changing identifiers", () => { + const context = createTestDatabase(); + try { + const firstCircuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(firstCircuit); + context.db + .insert(circuits) + .values({ + id: "circuit-2", + circuitListId: firstCircuit.circuitListId, + sectionId: firstCircuit.sectionId, + equipmentIdentifier: "-1F2", + sortOrder: 20, + isReserve: 1, + }) + .run(); + + const reordered = createService(context).executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitSectionReorderProjectCommand( + firstCircuit.sectionId, + [ + { + circuitId: "circuit-1", + expectedSortOrder: 10, + targetSortOrder: 20, + }, + { + circuitId: "circuit-2", + expectedSortOrder: 20, + targetSortOrder: 10, + }, + ] + ), + }); + assert.equal(reordered.history.undoDepth, 1); + assert.deepEqual( + context.db + .select({ + id: circuits.id, + equipmentIdentifier: circuits.equipmentIdentifier, + sortOrder: circuits.sortOrder, + }) + .from(circuits) + .all() + .sort((left, right) => left.id.localeCompare(right.id)), + [ + { + id: "circuit-1", + equipmentIdentifier: "-1F1", + sortOrder: 20, + }, + { + id: "circuit-2", + equipmentIdentifier: "-1F2", + sortOrder: 10, + }, + ] + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.deepEqual( + context.db + .select({ id: circuits.id, sortOrder: circuits.sortOrder }) + .from(circuits) + .all() + .sort((left, right) => left.id.localeCompare(right.id)), + [ + { id: "circuit-1", sortOrder: 10 }, + { id: "circuit-2", sortOrder: 20 }, + ] + ); + } finally { + context.close(); + } + }); + it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try {