192 lines
7.3 KiB
TypeScript
192 lines
7.3 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 { 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();
|
|
}
|
|
});
|
|
});
|