Add atomic Revit initial import
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
import crypto from "node:crypto";
|
||||
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 { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
||||
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createEmptyExternalModelState,
|
||||
createExternalInitialImportProjectCommand,
|
||||
} from "../src/domain/models/external-initial-import-project-command.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 type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
createExternalRoomKey,
|
||||
projectInitialExternalObject,
|
||||
} from "../src/external-model/domain/external-model-matching.js";
|
||||
import {
|
||||
externalCsvTestConfiguration,
|
||||
quotedRevitCsv,
|
||||
utf8Bytes,
|
||||
} from "./fixtures/revit-csv-fixtures.js";
|
||||
|
||||
function createTestContext() {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||
const configuration = configuredCsv();
|
||||
context.db.insert(externalCsvConfigurations).values({
|
||||
id: "config-1",
|
||||
projectId: "project-1",
|
||||
configurationVersion: 1,
|
||||
configuration,
|
||||
}).run();
|
||||
return { context, configuration };
|
||||
}
|
||||
|
||||
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" },
|
||||
},
|
||||
{
|
||||
exactFamilyAndType: "Steckdose: Standard",
|
||||
internalDeviceType: "Steckdose",
|
||||
connectionKind: "socket",
|
||||
category: "single_phase",
|
||||
quantityRule: { kind: "fixed", quantity: 1 },
|
||||
displayNameSuggestion: { kind: "selection-marker" },
|
||||
}
|
||||
);
|
||||
return configuration;
|
||||
}
|
||||
|
||||
function initialState(
|
||||
configuration: ReturnType<typeof configuredCsv>
|
||||
): ExternalModelStateSnapshot {
|
||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
||||
const preview = createExternalCsvPreview({
|
||||
fileName: "revit.csv",
|
||||
bytes,
|
||||
configuration,
|
||||
});
|
||||
const roomKeys = [...new Set(preview.objects.map((object) =>
|
||||
createExternalRoomKey(object.roomNumber, object.roomName)
|
||||
).filter((key): key is string => key !== null))];
|
||||
const roomMappings = roomKeys.map((key, index) => {
|
||||
const object = preview.objects.find(
|
||||
(candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key
|
||||
)!;
|
||||
return {
|
||||
id: `mapping-${index + 1}`,
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
normalizedSourceRoomKey: key,
|
||||
sourceFloorName: null,
|
||||
sourceRoomNumber: object.roomNumber,
|
||||
sourceRoomName: object.roomName,
|
||||
roomId: null,
|
||||
defaultDistributionBoardId: null,
|
||||
};
|
||||
});
|
||||
const mappingByKey = new Map(
|
||||
roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id])
|
||||
);
|
||||
return {
|
||||
source: {
|
||||
id: "source-1",
|
||||
projectId: "project-1",
|
||||
name: "Revit-Gesamtmodell",
|
||||
sourceType: "revit_csv",
|
||||
},
|
||||
importBatches: [{
|
||||
id: "batch-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
importKind: "initial",
|
||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||
fileName: "revit.csv",
|
||||
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||
appliedProjectRevision: 1,
|
||||
configurationVersion: 1,
|
||||
configurationSnapshot: configuration,
|
||||
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
||||
document: parseExternalCsv(bytes, configuration),
|
||||
}],
|
||||
roomMappings,
|
||||
objects: preview.objects.map((candidate, index) => {
|
||||
const projected = projectInitialExternalObject(candidate, configuration);
|
||||
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||
return {
|
||||
id: `object-${index + 1}`,
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
ifcGuid: projected.ifcGuid,
|
||||
lastSeenImportBatchId: "batch-1",
|
||||
lastAcceptedImportBatchId: "batch-1",
|
||||
acceptedSourceValues: projected.sourceValues,
|
||||
planningValues: projected.planningValues,
|
||||
overriddenFields: [],
|
||||
externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!,
|
||||
distributionBoardId: null,
|
||||
linkedProjectDeviceId: null,
|
||||
circuitDeviceRowId: null,
|
||||
presenceStatus: "present",
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("external initial import project command", () => {
|
||||
it("commits the complete import and supports persisted undo and redo", () => {
|
||||
const { context, configuration } = createTestContext();
|
||||
try {
|
||||
const target = initialState(configuration);
|
||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
description: "Revit-Erstimport übernehmen",
|
||||
command: createExternalInitialImportProjectCommand(
|
||||
createEmptyExternalModelState(),
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(inserted.revision.revisionNumber, 1);
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.objects.length,
|
||||
2
|
||||
);
|
||||
assert.ok(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.objects.every((object) => object.circuitDeviceRowId === null)
|
||||
);
|
||||
|
||||
const undone = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(undone.revision.revisionNumber, 2);
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.source,
|
||||
null
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "redo",
|
||||
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||
command: undone.inverse,
|
||||
});
|
||||
const redone = new ExternalModelStateRepository(context.db)
|
||||
.getByProject("project-1").state;
|
||||
assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]);
|
||||
assert.equal(redone.importBatches[0].configurationVersion, 1);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed state, checksum drift and row assignment", () => {
|
||||
const { context, configuration } = createTestContext();
|
||||
try {
|
||||
const target = initialState(configuration);
|
||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||
const assigned = structuredClone(target);
|
||||
assigned.objects[0].circuitDeviceRowId = "row-1";
|
||||
assert.throws(
|
||||
() => createExternalInitialImportProjectCommand(
|
||||
createEmptyExternalModelState(),
|
||||
assigned
|
||||
),
|
||||
/must not assign circuit device rows/
|
||||
);
|
||||
|
||||
const invalidHash = structuredClone(target);
|
||||
invalidHash.importBatches[0].sha256 = "b".repeat(64);
|
||||
assert.throws(
|
||||
() => repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createExternalInitialImportProjectCommand(
|
||||
createEmptyExternalModelState(),
|
||||
invalidHash
|
||||
),
|
||||
}),
|
||||
/checksum is invalid/
|
||||
);
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.source,
|
||||
null
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back all external rows when late history persistence fails", () => {
|
||||
const { context, configuration } = createTestContext();
|
||||
try {
|
||||
context.sqlite.exec(`
|
||||
CREATE TRIGGER fail_external_import_history
|
||||
BEFORE INSERT ON project_change_sets
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'late history failure');
|
||||
END;
|
||||
`);
|
||||
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||
assert.throws(
|
||||
() => repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createExternalInitialImportProjectCommand(
|
||||
createEmptyExternalModelState(),
|
||||
initialState(configuration)
|
||||
),
|
||||
}),
|
||||
/late history failure/
|
||||
);
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.source,
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,7 @@ describe("external model state repository", () => {
|
||||
fileName: "revit.csv",
|
||||
sha256: "hash",
|
||||
appliedProjectRevision: 1,
|
||||
configurationVersion: 1,
|
||||
configurationSnapshot: configuration,
|
||||
originalBytes: Buffer.from(bytes),
|
||||
document,
|
||||
@@ -388,6 +389,7 @@ function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseC
|
||||
fileName: "revit.csv",
|
||||
sha256: "a".repeat(64),
|
||||
appliedProjectRevision: 0,
|
||||
configurationVersion: 1,
|
||||
configurationSnapshot: configuration,
|
||||
originalBytes: Buffer.from(bytes),
|
||||
document: parseExternalCsv(bytes, configuration),
|
||||
|
||||
Reference in New Issue
Block a user