Add external model persistence foundation

This commit is contained in:
2026-08-02 17:18:24 +02:00
parent d0fc1a7caf
commit 76493b573e
15 changed files with 3207 additions and 8 deletions
+191
View File
@@ -0,0 +1,191 @@
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 { 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 { projects } from "../src/db/schema/projects.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,
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();
}
});
});