import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext } from "../src/db/database-context.js"; import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.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 { projectDevices } from "../src/db/schema/project-devices.js"; import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js"; import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; import { createExternalRoomKey, indexExternalObjectsByIfcGuid, projectInitialExternalObject, } from "../src/external-model/domain/external-model-matching.js"; import { externalCsvTestConfiguration, firstIfcGuid, quotedRevitCsv, utf8Bytes, } from "./fixtures/revit-csv-fixtures.js"; function configuredCsv() { const configuration = structuredClone(externalCsvTestConfiguration); configuration.familyTypeRules.push({ exactFamilyAndType: "Steckdose: Doppelsteckdose", internalDeviceType: "Steckdose", connectionKind: "socket", category: "single_phase", quantityRule: { kind: "mapped-column" }, displayNameSuggestion: { kind: "selection-marker" }, }); return configuration; } describe("external model matching", () => { it("normalizes source rooms and rejects duplicate IFC identities", () => { assert.equal(createExternalRoomKey(" 01/101 ", "Technik"), "number:01/101"); assert.equal(createExternalRoomKey("", " Büro Nord "), "name:BÜRO NORD"); assert.equal(createExternalRoomKey(" ", " "), null); assert.throws( () => indexExternalObjectsByIfcGuid([{ ifcGuid: firstIfcGuid }, { ifcGuid: firstIfcGuid }]), /Duplicate IfcGUID/ ); }); it("derives initial planning values from an exact family rule", () => { const configuration = configuredCsv(); const preview = createExternalCsvPreview({ fileName: "revit.csv", bytes: utf8Bytes(quotedRevitCsv, true), configuration, }); const projected = projectInitialExternalObject(preview.objects[0], configuration); assert.deepEqual(projected.issues, []); assert.equal(projected.ifcGuid, firstIfcGuid); assert.equal(projected.planningValues.displayName, "Arbeitsplatz"); assert.equal(projected.planningValues.category, "single_phase"); assert.equal(projected.planningValues.effectiveQuantity, 2); assert.equal(projected.planningValues.powerPerUnitW, 120.5); }); it("keeps unclassified or invalid values visible as issues", () => { const projected = projectInitialExternalObject( { rowNumber: 4, ifcGuid: firstIfcGuid, roomNumber: "", roomName: "", familyAndType: "Unbekannt", selectionMarker: "", circuitIdentifier: "", power: "12,3,4", quantity: "0", additionalSourceValues: {}, }, externalCsvTestConfiguration ); assert.deepEqual(projected.issues, [ "unknown-family-and-type", "missing-room", "invalid-power", ]); assert.equal(projected.planningValues.powerPerUnitW, null); }); }); describe("external model state repository", () => { it("reads a deterministic complete external state without changing project data", () => { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); try { context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); context.db.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit-Gesamtmodell", sourceType: "revit_csv", }).run(); const configuration = configuredCsv(); const bytes = utf8Bytes(quotedRevitCsv, true); const document = parseExternalCsv(bytes, configuration); context.db.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T15:30:00.000Z", fileName: "revit.csv", sha256: "hash", appliedProjectRevision: 1, configurationVersion: 1, configurationSnapshot: configuration, originalBytes: Buffer.from(bytes), document, }).run(); context.db.insert(externalRoomMappings).values({ id: "mapping-1", projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: "number:01/101", sourceFloorName: null, sourceRoomNumber: "01/101", sourceRoomName: "Technik", roomId: null, defaultDistributionBoardId: null, }).run(); const preview = createExternalCsvPreview({ fileName: "revit.csv", bytes, configuration, }); const projected = projectInitialExternalObject(preview.objects[0], configuration); context.db.insert(externalModelObjects).values({ id: "object-1", projectId: "project-1", sourceId: "source-1", ifcGuid: projected.ifcGuid, lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: projected.sourceValues, planningValues: projected.planningValues, overriddenFields: [], externalRoomMappingId: "mapping-1", distributionBoardId: null, linkedProjectDeviceId: null, circuitDeviceRowId: null, presenceStatus: "present", }).run(); const result = new ExternalModelStateRepository(context.db).getByProject("project-1"); assert.equal(result.projectExists, true); assert.equal(result.state.source?.id, "source-1"); assert.equal(result.state.importBatches[0].originalContentBase64, Buffer.from(bytes).toString("base64")); assert.equal(result.state.roomMappings[0].normalizedSourceRoomKey, "number:01/101"); assert.equal(result.state.objects[0].ifcGuid, firstIfcGuid); assert.equal(result.state.objects[0].circuitDeviceRowId, null); assert.equal(result.state.objects[0].planningValues.effectiveQuantity, 2); assert.throws( () => context.db.insert(externalModelObjects).values({ ...context.db.select().from(externalModelObjects).get()!, id: "object-2", }).run(), /UNIQUE constraint failed/ ); } finally { context.close(); } }); it("distinguishes an empty project from an unknown project", () => { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); try { context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run(); const repository = new ExternalModelStateRepository(context.db); assert.deepEqual(repository.getByProject("project-1"), { projectExists: true, state: { source: null, importBatches: [], roomMappings: [], objects: [] }, }); assert.equal(repository.getByProject("missing").projectExists, false); } finally { context.close(); } }); }); describe("external model snapshot and transfer", () => { it("captures, restores and undoes the complete external state", () => { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); try { insertLinkedExternalFixture(context.db); const populated = readProjectStateSnapshot(context.db, "project-1"); assert.ok(populated); assert.equal(populated.state.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1"); context.db.delete(externalModelSources).run(); const empty = readProjectStateSnapshot(context.db, "project-1"); assert.ok(empty); assert.equal(empty.state.externalModel.source, null); const repository = new ProjectStateRestoreCommandRepository(context.db); const restored = repository.execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createProjectStateRestoreCommand( empty.payloadSha256, populated.state ), }); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.objects[0].linkedProjectDeviceId, "device-1" ); repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: restored.revision.changeSetId, command: restored.inverse, }); assert.equal( new ExternalModelStateRepository(context.db).getByProject("project-1") .state.source, null ); } finally { context.close(); } }); it("remaps every internal external-model link for a project duplicate", () => { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); try { insertLinkedExternalFixture(context.db); const transferRepository = new ProjectTransferRepository(context.db); const transfer = transferRepository.exportProject("project-1"); assert.ok(transfer); const duplicate = transferRepository.importDuplicate(transfer); const copied = new ExternalModelStateRepository(context.db) .getByProject(duplicate.projectId).state; assert.notEqual(copied.source?.id, "source-1"); assert.notEqual(copied.importBatches[0].id, "batch-1"); assert.notEqual(copied.roomMappings[0].id, "mapping-1"); assert.notEqual(copied.objects[0].id, "object-1"); assert.equal(copied.objects[0].ifcGuid, firstIfcGuid); assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id); assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id); assert.notEqual(copied.objects[0].distributionBoardId, "board-1"); assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1"); assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1"); assert.equal( copied.importBatches[0].originalContentBase64, Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64") ); } finally { context.close(); } }); }); function insertLinkedExternalFixture(database: ReturnType["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: "01/101", roomName: "Technik", }).run(); database.insert(projectDevices).values({ id: "device-1", projectId: "project-1", name: "socket", displayName: "Steckdose", phaseType: "single_phase", connectionKind: "socket", costGroup: null, category: "single_phase", quantity: 1, powerPerUnit: 0.12, simultaneityFactor: 1, cosPhi: null, remark: null, voltageV: 230, }).run(); database.insert(distributionBoards).values({ id: "board-1", projectId: "project-1", name: "UV 1", floorId: "floor-1", supplyType: "AV", simultaneityFactor: 1, }).run(); database.insert(circuitLists).values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1 Stromkreisliste", }).run(); database.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single_phase", displayName: "1-phasig 1", 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: "Steckdosen", sortOrder: 10, voltage: 230, isReserve: 0, }).run(); database.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: "device-1", sortOrder: 10, name: "socket", displayName: "Steckdose", phaseType: "single_phase", connectionKind: "socket", costGroup: null, category: "single_phase", level: null, roomId: "room-1", roomNumberSnapshot: "01/101", roomNameSnapshot: "Technik", quantity: 2, powerPerUnit: 0.12, simultaneityFactor: 1, cosPhi: null, remark: null, overriddenFields: null, }).run(); database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit-Gesamtmodell", sourceType: "revit_csv", }).run(); const configuration = configuredCsv(); const bytes = utf8Bytes(quotedRevitCsv, true); database.insert(externalImportBatches).values({ id: "batch-1", projectId: "project-1", sourceId: "source-1", importKind: "initial", importedAtIso: "2026-08-02T15:30:00.000Z", fileName: "revit.csv", sha256: "a".repeat(64), appliedProjectRevision: 0, configurationVersion: 1, configurationSnapshot: configuration, originalBytes: Buffer.from(bytes), document: parseExternalCsv(bytes, configuration), }).run(); database.insert(externalRoomMappings).values({ id: "mapping-1", projectId: "project-1", sourceId: "source-1", normalizedSourceRoomKey: "number:01/101", sourceFloorName: "EG", sourceRoomNumber: "01/101", sourceRoomName: "Technik", roomId: "room-1", defaultDistributionBoardId: "board-1", }).run(); const preview = createExternalCsvPreview({ fileName: "revit.csv", bytes, configuration, }); const projected = projectInitialExternalObject(preview.objects[0], configuration); database.insert(externalModelObjects).values({ id: "object-1", projectId: "project-1", sourceId: "source-1", ifcGuid: projected.ifcGuid, lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1", acceptedSourceValues: projected.sourceValues, planningValues: projected.planningValues, overriddenFields: [], externalRoomMappingId: "mapping-1", distributionBoardId: "board-1", linkedProjectDeviceId: "device-1", circuitDeviceRowId: "row-1", presenceStatus: "present", }).run(); }