Files
leistungsbilanz-ts/tests/external-model-foundation.test.ts

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();
}