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 { CircuitListBulkUpdateProjectCommandRepository } from "../src/db/repositories/circuit-list-bulk-update-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 { projects } from "../src/db/schema/projects.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; import { assertCircuitListBulkUpdateProjectCommand, createCircuitListBulkUpdateProjectCommand, } from "../src/domain/models/circuit-list-bulk-update-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { createCircuitProtectionUpdateProjectCommand, type CircuitProtectionSnapshot, } from "../src/domain/models/circuit-protection-project-command.model.js"; import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; function createTestDatabase(): DatabaseContext { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations"), }); context.db .insert(projects) .values([ { id: "project-1", name: "Projekt" }, { id: "project-2", name: "Fremdprojekt" }, ]) .run(); const fixtures = new DistributionBoardFixtureRepository(context.db); fixtures.createWithCircuitListAndDefaultSections("project-1", "UV-01"); fixtures.createWithCircuitListAndDefaultSections("project-2", "UV-02"); const [list, foreignList] = ["project-1", "project-2"].map((projectId) => { const circuitList = context.db .select() .from(circuitLists) .where(eq(circuitLists.projectId, projectId)) .get(); assert.ok(circuitList); return circuitList; }); const sectionOf = (circuitListId: string) => { const section = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, circuitListId)) .get(); assert.ok(section); return section.id; }; context.db .insert(circuits) .values([ { id: "circuit-1", circuitListId: list.id, sectionId: sectionOf(list.id), equipmentIdentifier: "-1F1", displayName: "Leuchte", sortOrder: 10, }, { id: "circuit-2", circuitListId: list.id, sectionId: sectionOf(list.id), equipmentIdentifier: "-1F2", displayName: "Leuchte", sortOrder: 20, }, { id: "foreign-circuit", circuitListId: foreignList.id, sectionId: sectionOf(foreignList.id), equipmentIdentifier: "-1F1", sortOrder: 10, }, ]) .run(); context.db .insert(circuitDeviceRows) .values( ["circuit-1", "circuit-2"].map((circuitId, index) => ({ id: `row-${index + 1}`, circuitId, sortOrder: 10, name: "Leuchte", displayName: "Leuchte", quantity: 2, manualQuantity: 2, powerPerUnit: 0.1, simultaneityFactor: 1, })) ) .run(); context.db .insert(circuitProtectionDevices) .values(protectionFor("circuit-1", 10)) .run(); return context; } function protectionFor( circuitId: string, ratedCurrentA: number ): CircuitProtectionSnapshot { return { circuitId, type: "LS", ratedCurrentA, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: null, ratedResidualCurrentMa: null, }; } function readState(context: DatabaseContext) { return { circuits: context.db .select({ id: circuits.id, displayName: circuits.displayName, cableType: circuits.cableType, cableCrossSection: circuits.cableCrossSection, }) .from(circuits) .all(), rows: context.db .select({ id: circuitDeviceRows.id, displayName: circuitDeviceRows.displayName, powerPerUnit: circuitDeviceRows.powerPerUnit, simultaneityFactor: circuitDeviceRows.simultaneityFactor, }) .from(circuitDeviceRows) .all(), protections: context.db.select().from(circuitProtectionDevices).all(), }; } describe("circuit-list bulk update project command", () => { it("applies circuit, device-row and protection edits as one undoable revision", () => { const context = createTestDatabase(); try { const repository = new CircuitListBulkUpdateProjectCommandRepository( context.db ); const history = new ProjectHistoryRepository(context.db); const before = readState(context); const command = createCircuitListBulkUpdateProjectCommand([ createCircuitUpdateProjectCommand("circuit-1", { cableType: "NYM-J", cableCrossSection: "3x1,5", }), createCircuitUpdateProjectCommand("circuit-2", { cableType: "NYM-J", cableCrossSection: "3x1,5", }), createCircuitDeviceRowUpdateProjectCommand("row-1", { powerPerUnit: 0.25, simultaneityFactor: 0.8, }), createCircuitDeviceRowUpdateProjectCommand("row-2", { displayName: "Steckdose", }), createCircuitProtectionUpdateProjectCommand( "circuit-1", protectionFor("circuit-1", 10), protectionFor("circuit-1", 16) ), createCircuitProtectionUpdateProjectCommand( "circuit-2", null, protectionFor("circuit-2", 16) ), ]); const executed = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal(history.getState("project-1")?.currentRevision, 1); assert.equal(history.getState("project-1")?.undoDepth, 1); const after = readState(context); assert.deepEqual( after.circuits .filter((circuit) => circuit.id !== "foreign-circuit") .map((circuit) => [circuit.cableType, circuit.cableCrossSection]), [ ["NYM-J", "3x1,5"], ["NYM-J", "3x1,5"], ] ); // The single-device circuit follows its renamed row. assert.equal( after.circuits.find((circuit) => circuit.id === "circuit-2") ?.displayName, "Steckdose" ); assert.deepEqual( after.rows.find((row) => row.id === "row-1"), { id: "row-1", displayName: "Leuchte", powerPerUnit: 0.25, simultaneityFactor: 0.8, } ); assert.deepEqual( after.protections.map((protection) => protection.ratedCurrentA), [16, 16] ); // The inverse undoes the updates in reverse order. assert.deepEqual( executed.inverse.payload.updates.map((update) => update.type), [ "circuit-protection.update", "circuit-protection.update", "circuit-device-row.update", "circuit-device-row.update", "circuit.update", "circuit.update", ] ); const undo = history.getNextCommand("project-1", "undo"); assert.ok(undo); repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: undo.changeSetId, command: executed.inverse, }); assert.deepEqual(readState(context), before); const redo = history.getNextCommand("project-1", "redo"); assert.ok(redo); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: redo.changeSetId, command: (() => { assertCircuitListBulkUpdateProjectCommand(redo.command); return redo.command; })(), }); assert.deepEqual(readState(context), after); } finally { context.close(); } }); it("rolls back every update when one update is rejected", () => { const context = createTestDatabase(); try { const repository = new CircuitListBulkUpdateProjectCommandRepository( context.db ); const history = new ProjectHistoryRepository(context.db); const before = readState(context); const valid = createCircuitUpdateProjectCommand("circuit-1", { cableType: "NYM-J", }); for (const rejected of [ createCircuitUpdateProjectCommand("foreign-circuit", { cableType: "NYM-J", }), createCircuitProtectionUpdateProjectCommand( "circuit-1", protectionFor("circuit-1", 13), protectionFor("circuit-1", 16) ), ]) { assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createCircuitListBulkUpdateProjectCommand([ valid, rejected, ]), }), /does not belong to project|changed before update/ ); assert.deepEqual(readState(context), before); assert.equal(history.getState("project-1")?.currentRevision, 0); } } finally { context.close(); } }); it("rejects empty, duplicate and unsupported nested updates", () => { const update = createCircuitUpdateProjectCommand("circuit-1", { cableType: "NYM-J", }); assert.throws( () => createCircuitListBulkUpdateProjectCommand([]), /requires 1 to/ ); assert.throws( () => createCircuitListBulkUpdateProjectCommand([update, update]), /one target twice/ ); assert.throws( () => assertCircuitListBulkUpdateProjectCommand({ schemaVersion: 1, type: "circuit-list.bulk-update", payload: { updates: [ { schemaVersion: 1, type: "circuit.delete", payload: {} }, ], }, }), /unsupported update/ ); assert.throws( () => assertCircuitListBulkUpdateProjectCommand({ schemaVersion: 1, type: "circuit-list.bulk-update", payload: { updates: [update], extra: true }, }), /Unsupported circuit-list bulk update/ ); }); });