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 { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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"; import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js"; interface TestFixture { context: DatabaseContext; sectionId: string; secondSectionId: 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 DistributionBoardFixtureRepository(context.db); const board = boards.createWithCircuitListAndDefaultSections( "project-1", "UV-01" ); const foreignBoard = boards.createWithCircuitListAndDefaultSections( "project-2", "UV-02" ); const projectSections = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, board.id)) .all(); const section = projectSections[0]; const secondSection = projectSections[1]; const foreignSection = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, foreignBoard.id)) .get(); assert.ok(section); assert.ok(secondSection); 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, secondSectionId: secondSection.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("reorders multiple sections atomically with one undo step", () => { const fixture = createTestDatabase(); try { const firstCircuit = fixture.context.db .select() .from(circuits) .where(eq(circuits.id, "circuit-1")) .get(); assert.ok(firstCircuit); fixture.context.db .insert(circuits) .values([ { id: "circuit-4", circuitListId: firstCircuit.circuitListId, sectionId: fixture.secondSectionId, equipmentIdentifier: "-2F1", sortOrder: 10, }, { id: "circuit-5", circuitListId: firstCircuit.circuitListId, sectionId: fixture.secondSectionId, equipmentIdentifier: "-2F2", sortOrder: 20, }, ]) .run(); const store = new CircuitSectionReorderProjectCommandRepository( fixture.context.db ); const command = createCircuitSectionsReorderProjectCommand([ { sectionId: fixture.sectionId, assignments: createReorderCommand(fixture.sectionId) .payload.assignments, }, { sectionId: fixture.secondSectionId, assignments: [ { circuitId: "circuit-4", expectedSortOrder: 10, targetSortOrder: 20, }, { circuitId: "circuit-5", expectedSortOrder: 20, targetSortOrder: 10, }, ], }, ]); const reordered = store.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.deepEqual( fixture.context.db .select({ id: circuits.id, sortOrder: circuits.sortOrder }) .from(circuits) .all() .filter((circuit) => circuit.id !== "circuit-foreign") .sort((left, right) => left.id.localeCompare(right.id)), [ { id: "circuit-1", sortOrder: 20 }, { id: "circuit-2", sortOrder: 30 }, { id: "circuit-3", sortOrder: 10 }, { id: "circuit-4", sortOrder: 20 }, { id: "circuit-5", sortOrder: 10 }, ] ); store.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: reordered.revision.changeSetId, command: reordered.inverse, }); assert.deepEqual( fixture.context.db .select({ id: circuits.id, sortOrder: circuits.sortOrder }) .from(circuits) .all() .filter((circuit) => circuit.id !== "circuit-foreign") .sort((left, right) => left.id.localeCompare(right.id)), [ { id: "circuit-1", sortOrder: 10 }, { id: "circuit-2", sortOrder: 20 }, { id: "circuit-3", sortOrder: 30 }, { id: "circuit-4", sortOrder: 10 }, { id: "circuit-5", sortOrder: 20 }, ] ); assert.equal( new ProjectHistoryRepository(fixture.context.db).getState( "project-1" )?.redoDepth, 1 ); } finally { fixture.context.close(); } }); it("rolls back every section when a later section is stale", () => { const fixture = createTestDatabase(); try { const firstCircuit = fixture.context.db .select() .from(circuits) .where(eq(circuits.id, "circuit-1")) .get(); assert.ok(firstCircuit); fixture.context.db .insert(circuits) .values([ { id: "circuit-4", circuitListId: firstCircuit.circuitListId, sectionId: fixture.secondSectionId, equipmentIdentifier: "-2F1", sortOrder: 10, }, { id: "circuit-5", circuitListId: firstCircuit.circuitListId, sectionId: fixture.secondSectionId, equipmentIdentifier: "-2F2", sortOrder: 20, }, ]) .run(); const command = createCircuitSectionsReorderProjectCommand([ { sectionId: fixture.sectionId, assignments: createReorderCommand(fixture.sectionId) .payload.assignments, }, { sectionId: fixture.secondSectionId, assignments: [ { circuitId: "circuit-4", expectedSortOrder: 999, targetSortOrder: 20, }, { circuitId: "circuit-5", expectedSortOrder: 20, targetSortOrder: 10, }, ], }, ]); assert.throws( () => new CircuitSectionReorderProjectCommandRepository( fixture.context.db ).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }), /changed before/ ); assert.deepEqual( getCircuitState(fixture.context).map( (circuit) => circuit.sortOrder ), [10, 20, 30, 10, 20] ); assert.equal( fixture.context.db.select().from(projectRevisions).all() .length, 0 ); } 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(); } }); });