432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
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<typeof createDatabaseContext>["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();
|
|
}
|