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 AppDatabase } from "../src/db/database-context.js"; import { ExternalObjectAssignmentPlanProjectCommandRepository } from "../src/db/repositories/external-object-assignment-plan-project-command.repository.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.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 { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; import { floors } from "../src/db/schema/floors.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import type { CircuitSnapshot } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createExternalObjectAssignmentPlanProjectCommand, externalObjectApplyAssignmentPlanCommandType, externalObjectRevertAssignmentPlanCommandType, } from "../src/domain/models/external-object-assignment-plan-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; function createFixture() { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); const database = context.db; database.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); database.insert(floors).values({ id: "floor-1", projectId: "project-1", name: "EG", sortOrder: 10 }).run(); for (const [id, number] of [["room-1", "101"], ["room-2", "102"]] as const) { database.insert(rooms).values({ id, projectId: "project-1", floorId: "floor-1", roomNumber: number, roomName: "Büro" }).run(); } database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1" }).run(); database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "Liste" }).run(); database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run(); database.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", displayName: "Steckdose", sortOrder: 10, voltage: 230 }).run(); database.insert(circuits).values({ id: "circuit-2", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.2", displayName: "Reserve", sortOrder: 20, voltage: 230, isReserve: 1 }).run(); database.insert(circuitDeviceRows).values(rowValues({ id: "row-1", circuitId: "circuit-1", quantity: 2 })).run(); database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run(); database.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-10-30T10:00:00.000Z", fileName: "revit.xlsx", sha256: "a".repeat(64), objectCount: 4, }).run(); for (const [id, roomId, number] of [["mapping-1", "room-1", "101"], ["mapping-2", "room-2", "102"]] as const) { database.insert(externalRoomMappings).values({ id, projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: `number:${number}`, sourceFloorName: "EG", sourceRoomNumber: number, sourceRoomName: "Büro", roomId, defaultDistributionBoardId: "board-1", }).run(); } database.insert(externalModelObjects).values([ objectSnapshot("object-0", "mapping-1", "row-1"), objectSnapshot("object-a1", "mapping-1", null), objectSnapshot("object-a2", "mapping-1", null), objectSnapshot("object-b1", "mapping-2", null), ]).run(); return context; } function rowValues(overrides: Partial & { id: string; circuitId: string }): CircuitDeviceRowSnapshot { return { linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose: Doppel", displayName: "Steckdose", phaseType: "single_phase", connectionKind: "socket", costGroup: null, category: "single_phase", level: null, roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", quantity: 2, manualQuantity: 0, powerPerUnit: 0.12, simultaneityFactor: 1, cosPhi: null, remark: null, overriddenFields: null, ...overrides, }; } const worksharingIds: Record = { "object-0": "00000000-0000-4000-8000-000000000000-00000001", "object-a1": "00000000-0000-4000-8000-000000000001-00000001", "object-a2": "00000000-0000-4000-8000-000000000002-00000001", "object-b1": "00000000-0000-4000-8000-000000000003-00000001", }; function objectSnapshot(id: string, mappingId: string, circuitDeviceRowId: string | null): ExternalModelObjectSnapshot { const roomNumber = mappingId === "mapping-1" ? "101" : "102"; return { id, projectId: "project-1", sourceId: "source-1", worksharingId: worksharingIds[id]!, ifcGuid: null, deviceTypeId: "device-type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: { rowNumber: 3, roomNumber, roomName: "Büro", familyAndType: "Steckdose: Doppel", selectionMarker: "SD", socketCount: "2", sourceRemark: null, circuitCombined: null, boardName: null, circuitIdentifier: null, designation: null, electricalPowerW: null, voltageV: null, currentA: null, }, planningValues: { displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null, }, overriddenFields: [], externalRoomMappingId: mappingId, distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId, presenceStatus: "present", }; } function link(id: string, mappingId: string, rowId: string) { const expected = objectSnapshot(id, mappingId, null); return { expected, target: { ...expected, circuitDeviceRowId: rowId } }; } function planCommand(database: AppDatabase) { const existing = toCircuitDeviceRowSnapshot( database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()! ); const newCircuit: CircuitSnapshot = { id: "circuit-new", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.3", displayName: "Steckdose", sortOrder: 30, cableType: null, cableCrossSection: null, cableLength: null, rcdAssignment: null, terminalDesignation: null, voltage: 230, controlRequirement: null, status: null, isReserve: false, remark: null, deviceRows: [rowValues({ id: "row-new-b", circuitId: "circuit-new", roomId: "room-2", roomNumberSnapshot: "102" })], protectionDevice: { circuitId: "circuit-new", type: "FI_LS", ratedCurrentA: 16, fuseUtilizationCategory: null, tripCharacteristic: "B", rcdType: "A", ratedResidualCurrentMa: 30, }, }; return createExternalObjectAssignmentPlanProjectCommand(externalObjectApplyAssignmentPlanCommandType, { newCircuits: [newCircuit], newRows: [rowValues({ id: "row-new-a", circuitId: "circuit-2" })], rows: [{ expected: existing, target: { ...existing, quantity: 4 } }], objects: [ link("object-a1", "mapping-1", "row-1"), link("object-a2", "mapping-1", "row-new-a"), link("object-b1", "mapping-2", "row-new-b"), ], confirmedConflictObjectIds: [], }); } function readState(database: AppDatabase) { const circuitRows = database.select().from(circuits).all() .map(({ id, displayName, isReserve, equipmentIdentifier }) => ({ id, displayName, isReserve, equipmentIdentifier })) .sort((left, right) => left.id.localeCompare(right.id)); const rows = database.select().from(circuitDeviceRows).all() .map(({ id, circuitId, quantity }) => ({ id, circuitId, quantity })) .sort((left, right) => left.id.localeCompare(right.id)); const links = Object.fromEntries( database.select().from(externalModelObjects).all().map((object) => [object.id, object.circuitDeviceRowId]) ); const protections = database.select().from(circuitProtectionDevices).all().map((entry) => entry.circuitId); const revision = database.select().from(projects).get()!.currentRevision; return { circuitRows, rows, links, protections, revision }; } describe("external object assignment-plan project command", () => { it("applies a plan atomically and restores it exactly through undo and redo", () => { const context = createFixture(); try { const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); const before = readState(context.db); const applied = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db), }); const after = readState(context.db); assert.deepEqual(after.links, { "object-0": "row-1", "object-a1": "row-1", "object-a2": "row-new-a", "object-b1": "row-new-b", }); assert.deepEqual(after.rows.find((entry) => entry.id === "row-1"), { id: "row-1", circuitId: "circuit-1", quantity: 4 }); assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-2"), { id: "circuit-2", displayName: "Steckdose", isReserve: 0, equipmentIdentifier: "-2F1.2", }); assert.deepEqual(after.circuitRows.find((entry) => entry.id === "circuit-new")?.equipmentIdentifier, "-2F1.3"); assert.deepEqual(after.protections, ["circuit-new"]); assert.equal(applied.inverse.type, externalObjectRevertAssignmentPlanCommandType); assert.deepEqual(applied.inverse.payload.circuitDisplayNames, [ { circuitId: "circuit-2", expected: "Steckdose", target: "Reserve" }, ]); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse, }); assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...before, revision: 0 }); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse, }); assert.deepEqual({ ...readState(context.db), revision: 0 }, { ...after, revision: 0 }); } finally { context.close(); } }); it("rolls back completely when an object changed and rejects a user revert", () => { const context = createFixture(); try { const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); const command = planCommand(context.db); context.db.update(externalModelObjects) .set({ circuitDeviceRowId: "row-1" }) .where(eq(externalModelObjects.id, "object-b1")).run(); const before = readState(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command }), /changed before plan assignment/ ); assert.deepEqual(readState(context.db), before); const revert = createExternalObjectAssignmentPlanProjectCommand( externalObjectRevertAssignmentPlanCommandType, { ...command.payload, objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })) } ); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: revert }), /only be reverted through project history/ ); } finally { context.close(); } }); it("refuses undo after a created row was changed", () => { const context = createFixture(); try { const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); const applied = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db), }); context.db.update(circuitDeviceRows).set({ remark: "geändert" }) .where(eq(circuitDeviceRows.id, "row-new-a")).run(); const before = readState(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse, }), /changed before history removal/ ); assert.deepEqual(readState(context.db), before); } finally { context.close(); } }); it("requires explicit confirmation of planning-value conflicts on existing rows", () => { const context = createFixture(); try { context.db.update(circuitDeviceRows).set({ simultaneityFactor: 0.5 }) .where(eq(circuitDeviceRows.id, "row-1")).run(); const repository = new ExternalObjectAssignmentPlanProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: planCommand(context.db) }), /explicit conflict confirmation/ ); const confirmed = createExternalObjectAssignmentPlanProjectCommand( externalObjectApplyAssignmentPlanCommandType, { ...planCommand(context.db).payload, confirmedConflictObjectIds: ["object-a1"] } ); assert.doesNotThrow(() => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: confirmed }) ); } finally { context.close(); } }); });