Add external model persistence foundation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user