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 { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-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 { 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 { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.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(); database.insert(rooms).values({ id: "room-1", projectId: "project-1", floorId: "floor-1", roomNumber: "101", 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(); for (const index of [1, 2]) { database.insert(circuits).values({ id: `circuit-${index}`, circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: `-2F1.${index}`, sortOrder: index * 10, voltage: 230 }).run(); database.insert(circuitDeviceRows).values({ id: `row-${index}`, circuitId: `circuit-${index}`, sortOrder: 10, name: "socket", displayName: "Steckdose", phaseType: "single_phase", connectionKind: "socket", category: "single_phase", roomId: "room-1", roomNumberSnapshot: "101", roomNameSnapshot: "Büro", quantity: index === 1 ? 3 : 0, manualQuantity: index === 1 ? 1 : 0, powerPerUnit: 0.12, simultaneityFactor: 1, }).run(); } database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run(); database.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T16:00:00.000Z", fileName: "revit.csv", sha256: "a".repeat(64), appliedProjectRevision: 0, configurationVersion: 1, configurationSnapshot: externalCsvTestConfiguration, originalBytes: Buffer.from("test"), document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, }).run(); database.insert(externalRoomMappings).values({ id: "mapping-1", projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: "number:101", sourceFloorName: "EG", sourceRoomNumber: "101", sourceRoomName: "Büro", roomId: "room-1", defaultDistributionBoardId: "board-1", }).run(); database.insert(externalModelObjects).values(objectSnapshot("row-1")).run(); return context; } function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectSnapshot { return { id: "object-1", projectId: "project-1", sourceId: "source-1", ifcGuid: "ifc-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose: Standard", selectionMarker: "Steckdose", circuitIdentifier: "-2F1.1", power: "120", quantity: "2", additionalSourceValues: {}, }, planningValues: { displayName: "Steckdose", internalDeviceType: "socket", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: null, costGroup: null, remark: null, }, overriddenFields: [], externalRoomMappingId: "mapping-1", distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId, presenceStatus: "present", }; } function row(database: AppDatabase, id: string) { return toCircuitDeviceRowSnapshot( database.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, id)).get()! ); } function moveCommand(database: AppDatabase, confirmedConflictObjectIds: string[] = []) { const row1 = row(database, "row-1"); const row2 = row(database, "row-2"); const object = objectSnapshot("row-1"); return createExternalObjectRowAssignmentProjectCommand({ rows: [ { expected: row1, target: { ...row1, quantity: 1 } }, { expected: row2, target: { ...row2, quantity: 2 } }, ], objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-2" } }], confirmedConflictObjectIds, }); } describe("external object row assignment project command", () => { it("moves an object atomically and preserves exact quantities through undo and redo", () => { const context = createFixture(); try { const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); const moved = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }); assert.equal(row(context.db, "row-1").quantity, 1); assert.equal(row(context.db, "row-2").quantity, 2); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: moved.revision.changeSetId, command: moved.inverse }); assert.equal(row(context.db, "row-1").quantity, 3); assert.equal(row(context.db, "row-2").quantity, 0); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: moved.revision.changeSetId, command: undone.inverse }); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); } finally { context.close(); } }); it("detaches the last object but keeps the now-manual row", () => { const context = createFixture(); try { const currentRow = row(context.db, "row-1"); const object = objectSnapshot("row-1"); const command = createExternalObjectRowAssignmentProjectCommand({ rows: [{ expected: currentRow, target: { ...currentRow, quantity: 1 } }], objects: [{ expected: object, target: { ...object, circuitDeviceRowId: null } }], confirmedConflictObjectIds: [], }); new ExternalObjectRowAssignmentProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command, }); assert.equal(row(context.db, "row-1").quantity, 1); assert.equal(row(context.db, "row-1").manualQuantity, 1); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null); } finally { context.close(); } }); it("requires explicit confirmation for differing planning values", () => { const context = createFixture(); try { context.db.update(circuitDeviceRows).set({ displayName: "Abweichend" }).where(eq(circuitDeviceRows.id, "row-2")).run(); const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), /explicit conflict confirmation/ ); repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db, ["object-1"]) }); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-2"); } finally { context.close(); } }); it("rolls back row quantities and links when history persistence fails", () => { const context = createFixture(); try { context.sqlite.exec(` CREATE TRIGGER fail_assignment_history BEFORE INSERT ON project_change_sets BEGIN SELECT RAISE(ABORT, 'late history failure'); END; `); const repository = new ExternalObjectRowAssignmentProjectCommandRepository(context.db); assert.throws( () => repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: moveCommand(context.db) }), /late history failure/ ); assert.equal(row(context.db, "row-1").quantity, 3); assert.equal(row(context.db, "row-2").quantity, 0); assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-1"); } finally { context.close(); } }); });