Replace Revit CSV backend with Linear XLSX

This commit is contained in:
Julian Appel 2026-09-30 08:46:07 +02:00
parent 52d3b1a270
commit 1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions

View file

@ -1,105 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalCircuitListObjects } from "../src/external-model/application/external-circuit-list-objects.js";
import type {
ExternalModelObjectSnapshot,
ExternalModelStateSnapshot,
} from "../src/external-model/domain/external-model-contracts.js";
function object(input: {
id: string;
distributionBoardId: string;
circuitDeviceRowId: string | null;
roomNumber: string;
}): ExternalModelObjectSnapshot {
return {
id: input.id,
projectId: "project-1",
sourceId: "source-1",
ifcGuid: `ifc-${input.id}`,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
rowNumber: 2,
roomNumber: input.roomNumber,
roomName: "Büro",
familyAndType: "Steckdose: Standard",
selectionMarker: `S-${input.id}`,
circuitIdentifier: "-2F1.1",
power: "120",
quantity: "1",
additionalSourceValues: {},
},
planningValues: {
displayName: "Steckdose",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "socket",
effectiveQuantity: 1,
powerPerUnitW: 120,
simultaneityFactor: 1,
cosPhi: null,
costGroup: null,
remark: null,
},
overriddenFields: [],
externalRoomMappingId: input.distributionBoardId === "board-1" ? "mapping-1" : null,
distributionBoardId: input.distributionBoardId,
linkedProjectDeviceId: null,
circuitDeviceRowId: input.circuitDeviceRowId,
presenceStatus: "present",
};
}
describe("external circuit-list object projection", () => {
it("returns only the board objects with unassigned objects first", () => {
const state: ExternalModelStateSnapshot = {
source: {
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
},
importBatches: [],
roomMappings: [{
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:101",
sourceFloorName: "EG",
sourceRoomNumber: "101",
sourceRoomName: "Besprechung",
roomId: "room-1",
defaultDistributionBoardId: "board-1",
}],
objects: [
object({ id: "assigned", distributionBoardId: "board-1", circuitDeviceRowId: "row-1", roomNumber: "102" }),
object({ id: "unassigned", distributionBoardId: "board-1", circuitDeviceRowId: null, roomNumber: "101" }),
object({ id: "other-board", distributionBoardId: "board-2", circuitDeviceRowId: null, roomNumber: "001" }),
],
};
const result = createExternalCircuitListObjects({
state,
distributionBoardId: "board-1",
});
assert.equal(result.sourceName, "Revit-Gesamtmodell");
assert.equal(result.objectCount, 2);
assert.equal(result.unassignedObjectCount, 1);
assert.deepEqual(result.objects.map(({ id }) => id), ["unassigned", "assigned"]);
assert.equal(result.objects[0].sourceRoomName, "Besprechung");
assert.equal(result.objects[0].roomId, "room-1");
assert.equal(result.objects[0].sourceCircuitIdentifier, "-2F1.1");
});
it("returns an empty projection before the optional Revit import", () => {
assert.deepEqual(
createExternalCircuitListObjects({
state: { source: null, importBatches: [], roomMappings: [], objects: [] },
distributionBoardId: "board-1",
}),
{ sourceName: null, objectCount: 0, unassignedObjectCount: 0, objects: [] }
);
});
});

View file

@ -1,83 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
applyExternalInitialImportSchema,
MAX_CSV_CONTENT_BASE64_LENGTH,
planExternalInitialImportSchema,
previewExternalCsvSchema,
updateExternalCsvConfigurationSchema,
} from "../src/shared/validation/external-csv.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
describe("external CSV API contracts", () => {
it("accepts strict update and preview request envelopes", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: 4,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
}).success,
true
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
}).success,
true
);
assert.equal(
applyExternalInitialImportSchema.safeParse({
expectedRevision: 5,
expectedConfigurationVersion: 2,
expectedSha256: "a".repeat(64),
fileName: "revit.csv",
contentBase64: "YWJj",
sourceName: "Revit-Gesamtmodell",
roomDecisions: [],
familyProjectDeviceDecisions: [],
}).success,
true
);
});
it("rejects stale-shaped, oversized and unknown request fields", () => {
assert.equal(
updateExternalCsvConfigurationSchema.safeParse({
expectedRevision: -1,
configuration: {},
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
apply: true,
}).success,
false
);
assert.equal(
planExternalInitialImportSchema.safeParse({
fileName: "revit.csv",
contentBase64: "YWJj",
decisions: [],
}).success,
false
);
assert.equal(
previewExternalCsvSchema.safeParse({
fileName: "revit.csv",
contentBase64: "a".repeat(MAX_CSV_CONTENT_BASE64_LENGTH + 1),
}).success,
false
);
});
});

View file

@ -1,244 +0,0 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalCsvConfigurationRepository } from "../src/db/repositories/external-csv-configuration.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.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 { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
return context;
}
function snapshot(version = 1) {
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Standard",
internalDeviceType: "Steckdose",
connectionKind: null,
category: "single_phase",
quantityRule: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
return {
id: "external-config-1",
projectId: "project-1",
configurationVersion: version,
configuration,
};
}
describe("external CSV configuration project command", () => {
it("persists insert and update with exact inverse commands", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Revit-CSV-Konfiguration anlegen",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.deepEqual(readConfiguration(context), initial);
assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null });
const target = structuredClone(initial);
target.configurationVersion = 2;
target.configuration.decimalSeparator = ".";
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, target),
});
assert.equal(updated.revision.revisionNumber, 2);
assert.deepEqual(readConfiguration(context), target);
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
} finally {
context.close();
}
});
it("rejects stale state and cross-project targets without a partial revision", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)),
}),
/changed before update/
);
const foreign = snapshot(2);
foreign.projectId = "project-2";
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign),
}),
/identity must remain stable|different project/
);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
const skippedVersion = snapshot(3);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion),
}),
/version must advance by one/
);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial),
}),
/did not change/
);
} finally {
context.close();
}
});
it("includes configuration in snapshots and remaps it for project duplicates", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const state = readProjectStateSnapshot(context.db, "project-1")?.state;
assert.deepEqual(state?.externalCsvConfiguration, initial);
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const duplicatedConfiguration = context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, duplicate.projectId))
.get();
assert.ok(duplicatedConfiguration);
assert.notEqual(duplicatedConfiguration.id, initial.id);
assert.equal(duplicatedConfiguration.projectId, duplicate.projectId);
assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration);
} finally {
context.close();
}
});
it("distinguishes projects without configuration from unknown projects", () => {
const context = createTestDatabase();
try {
const reader = new ExternalCsvConfigurationRepository(context.db);
assert.deepEqual(reader.getByProject("project-1"), {
projectExists: true,
configuration: null,
});
assert.deepEqual(reader.getByProject("missing"), {
projectExists: false,
configuration: null,
});
} finally {
context.close();
}
});
it("restores and undoes the complete configuration through snapshot history", () => {
const context = createTestDatabase();
try {
const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const configuredState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(configuredState);
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, null),
});
const emptyState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(emptyState);
const restoreRepository = new ProjectStateRestoreCommandRepository(context.db);
const restored = restoreRepository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createProjectStateRestoreCommand(
emptyState.payloadSha256,
configuredState.state
),
});
assert.deepEqual(readConfiguration(context), initial);
restoreRepository.execute({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.equal(readConfiguration(context), null);
} finally {
context.close();
}
});
});
function readConfiguration(context: DatabaseContext) {
return context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, "project-1"))
.get() ?? null;
}

View file

@ -1,56 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external CSV preview", () => {
it("projects transport statistics and mapped object source values without writes", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
configuration.additionalSourceMappings.push({
sourceColumn: "Raumname",
targetField: "sourceRoomLabel",
});
// The additional mapping intentionally needs a distinct source column in
// persisted configuration, so use a separately named synthetic header.
configuration.additionalSourceMappings[0].sourceColumn = "Zusatzwert";
const csv = quotedRevitCsv
.replace('"Anzahl";"IfcGUID"', '"Anzahl";"Zusatzwert";"IfcGUID"')
.replaceAll(';"2";"0Ab', ';"2";"A";"0Ab')
.replaceAll(';"1";"1Ab', ';"1";"B";"1Ab')
.replaceAll(';"1";""', ';"1";"C";""')
.replaceAll(';"";""\r\n', ';"";"";""\r\n');
const preview = createExternalCsvPreview({
fileName: "export.csv",
bytes: utf8Bytes(csv, true),
configuration,
});
assert.equal(preview.fileName, "export.csv");
assert.equal(preview.headerRowNumber, 2);
assert.equal(preview.rowCount, 7);
assert.equal(preview.objectCount, 2);
assert.equal(preview.passthroughCount, 2);
assert.equal(preview.nonEmptyPassthroughCount, 1);
assert.equal(preview.suspectObjectCount, 1);
assert.deepEqual(preview.suspectRowNumbers, [6]);
assert.match(preview.sha256, /^[a-f0-9]{64}$/);
assert.deepEqual(preview.objects[0], {
rowNumber: 4,
ifcGuid: firstIfcGuid,
roomNumber: "01/101",
roomName: "Technik",
familyAndType: "Steckdose: Doppelsteckdose",
selectionMarker: "Arbeitsplatz",
circuitIdentifier: "UV_AV_01-2F1",
power: "120,5",
quantity: "2",
additionalSourceValues: { sourceRoomLabel: "A" },
});
});
});

View file

@ -1,146 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ExternalCsvParseError,
parseExternalCsv,
serializeExternalCsv,
} from "../src/external-model/csv/external-csv-transport.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external CSV transport", () => {
it("detects the observed Revit dialect and classifies every row", () => {
const document = parseExternalCsv(
utf8Bytes(quotedRevitCsv, true),
externalCsvTestConfiguration
);
assert.deepEqual(document.dialect, {
encoding: "utf-8",
hasBom: true,
delimiter: ";",
lineEnding: "\r\n",
quoteCharacter: '"',
quoteAllFields: true,
hasTrailingLineEnding: true,
});
assert.equal(document.headerRowIndex, 1);
assert.deepEqual(
document.rows.map((row) => row.classification),
[
"metadata",
"header",
"passthrough",
"object",
"passthrough",
"suspect-object",
"object",
]
);
assert.equal(document.rows[6].cells[2].value, 'Büro "Nord"');
});
it("round-trips BOM, CRLF, quotation, order and cell values byte for byte", () => {
const source = utf8Bytes(quotedRevitCsv, true);
const document = parseExternalCsv(source, externalCsvTestConfiguration);
assert.deepEqual(serializeExternalCsv(document), source);
});
it("parses exports without an optional quantity column", () => {
const configuration = {
...externalCsvTestConfiguration,
columns: {
...externalCsvTestConfiguration.columns,
quantity: null,
},
};
const csv = [
'"Projekt-Export";"";"";"";"";"";""',
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"IfcGUID"',
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Standard";"Arbeitsplatz";"100";"${firstIfcGuid}"`,
].join("\r\n") + "\r\n";
const document = parseExternalCsv(utf8Bytes(csv, true), configuration);
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
1
);
});
it("preserves embedded delimiters and line endings inside quoted cells", () => {
const csv = quotedRevitCsv.replace(
'"Steckdose: Doppelsteckdose"',
'"Steckdose; Doppel\r\nsteckdose"'
);
const source = utf8Bytes(csv, true);
const document = parseExternalCsv(source, externalCsvTestConfiguration);
assert.equal(
document.rows[3].cells[3].value,
"Steckdose; Doppel\r\nsteckdose"
);
assert.deepEqual(serializeExternalCsv(document), source);
});
it("blocks missing and ambiguous configured headers", () => {
assertParseError(
utf8Bytes(quotedRevitCsv.replace("IfcGUID", "Andere ID")),
"header-not-found"
);
const duplicatedHeader = quotedRevitCsv.replace(
'"";"";"";"";"";"";"";""',
quotedRevitCsv.split("\r\n")[1]
);
assertParseError(utf8Bytes(duplicatedHeader), "ambiguous-header");
const duplicateConfiguredColumn = quotedRevitCsv.replace(
'"Stromkreis";"Raumnummer"',
'"Stromkreis";"Raumnummer";"Raumnummer"'
);
assertParseError(
utf8Bytes(duplicateConfiguredColumn),
"ambiguous-header"
);
});
it("blocks invalid and duplicate non-empty IfcGUIDs", () => {
assertParseError(
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, "not-an-ifc-guid")),
"invalid-ifc-guid"
);
assertParseError(
utf8Bytes(quotedRevitCsv.replace(firstIfcGuid, ` ${firstIfcGuid}`)),
"invalid-ifc-guid"
);
const duplicate = quotedRevitCsv.replace(
"1AbCdEfGhIjKlMnOpQrStu",
firstIfcGuid
);
assertParseError(utf8Bytes(duplicate), "duplicate-ifc-guid");
});
it("blocks invalid UTF-8, unterminated quotes and mixed row endings", () => {
assertParseError(new Uint8Array([0xff, 0xfe, 0xfd]), "invalid-encoding");
assertParseError(
utf8Bytes(quotedRevitCsv.replace(/"\r\n$/, "\r\n")),
"invalid-csv"
);
assertParseError(
utf8Bytes(quotedRevitCsv.replace("\r\n", "\n")),
"invalid-csv"
);
});
});
function assertParseError(input: Uint8Array, code: ExternalCsvParseError["code"]) {
assert.throws(
() => parseExternalCsv(input, externalCsvTestConfiguration),
(error: unknown) => error instanceof ExternalCsvParseError && error.code === code
);
}

View file

@ -1,149 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createExternalInitialImportPlan } from "../src/external-model/application/external-initial-import-plan.js";
import { buildExternalInitialImportTarget } from "../src/external-model/application/external-initial-import-target.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import {
externalCsvTestConfiguration,
firstIfcGuid,
quotedRevitCsv,
utf8Bytes,
} from "./fixtures/revit-csv-fixtures.js";
describe("external initial import plan", () => {
it("groups source rooms and exposes exact room and classification suggestions", () => {
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" },
});
const plan = createExternalInitialImportPlan({
fileName: "revit.csv",
bytes: utf8Bytes(quotedRevitCsv, true),
configurationVersion: 4,
configuration,
floors: [{ id: "floor-1", name: "EG", sortOrder: 10 }],
rooms: [{
id: "room-1",
floorId: "floor-1",
roomNumber: "01/101",
roomName: "Technik Bestand",
}],
distributionBoards: [{ id: "board-1", name: "UV 1", floorId: "floor-1" }],
projectDevices: [{
id: "device-1",
name: "socket",
displayName: "Steckdose",
category: "single_phase",
connectionKind: "socket",
powerPerUnit: 0.12,
}],
});
assert.equal(plan.configurationVersion, 4);
assert.equal(plan.objectCount, 2);
assert.equal(plan.sourceRooms.length, 2);
assert.deepEqual(
plan.sourceRooms.map((room) => ({
number: room.roomNumber,
status: room.matchStatus,
suggested: room.suggestedRoomId,
})),
[
{ number: "01/101", status: "exact-number", suggested: "room-1" },
{ number: "01/103", status: "new", suggested: null },
]
);
assert.equal(plan.familyGroups.length, 2);
assert.equal(plan.issueCounts.unknownFamilyAndType, 1);
assert.equal(plan.issueCounts.invalidPower, 0);
assert.equal(plan.issueCounts.invalidQuantity, 0);
assert.equal(plan.objects[0].ifcGuid, firstIfcGuid);
assert.equal(plan.objects[0].suggestedPlanningValues.effectiveQuantity, 2);
assert.equal(plan.existing.distributionBoards[0].id, "board-1");
assert.equal(plan.existing.projectDevices[0].id, "device-1");
});
it("builds a stable row-free target only after every family is classified", () => {
const configuration = structuredClone(externalCsvTestConfiguration);
for (const exactFamilyAndType of [
"Steckdose: Doppelsteckdose",
"Steckdose: Standard",
]) {
configuration.familyTypeRules.push({
exactFamilyAndType,
internalDeviceType: "Steckdose",
connectionKind: "socket",
category: "single_phase",
quantityRule: exactFamilyAndType.includes("Doppel")
? { kind: "mapped-column" }
: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
}
const bytes = utf8Bytes(quotedRevitCsv, true);
const plan = createExternalInitialImportPlan({
fileName: "revit.csv",
bytes,
configurationVersion: 2,
configuration,
floors: [],
rooms: [],
distributionBoards: [],
projectDevices: [],
});
let id = 0;
const target = buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: plan.sourceRooms.map((room) => ({
sourceRoomKey: room.sourceRoomKey,
roomId: null,
defaultDistributionBoardId: null,
})),
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => `generated-${++id}`,
});
assert.equal(target.importBatches[0].appliedProjectRevision, 8);
assert.equal(target.importBatches[0].configurationVersion, 2);
assert.equal(target.objects.length, 2);
assert.ok(target.objects.every((object) => object.circuitDeviceRowId === null));
assert.ok(target.objects.every((object) => object.overriddenFields.length === 0));
assert.throws(
() => buildExternalInitialImportTarget({
projectId: "project-1",
expectedRevision: 7,
sourceName: "Revit-Gesamtmodell",
importedAtIso: "2026-08-02T17:00:00.000Z",
configurationVersion: 2,
configuration,
originalContentBase64: Buffer.from(bytes).toString("base64"),
document: parseExternalCsv(bytes, configuration),
plan,
roomDecisions: [],
familyProjectDeviceDecisions: plan.familyGroups.map((group) => ({
familyAndType: group.familyAndType,
projectDeviceId: null,
})),
createId: () => "unused",
}),
/Quellraum-Entscheidungen/
);
});
});

View file

@ -1,297 +0,0 @@
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 a target state with zero classified objects before writing anything", () => {
// assertPopulatedInitialState requires at least one object, so a CSV
// that classified none must be rejected at command construction, not
// reach the persistence layer's bulk insert.
const { context, configuration } = createTestContext();
try {
const target: ExternalModelStateSnapshot = {
...initialState(configuration),
roomMappings: [],
objects: [],
};
assert.throws(
() =>
createExternalInitialImportProjectCommand(
createEmptyExternalModelState(),
target
),
/at least one object/
);
} 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();
}
});
});

View file

@ -0,0 +1,132 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext, type DatabaseContext } from "../src/db/database-context.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
import { ExternalModelConfigurationRepository } from "../src/db/repositories/external-model-configuration.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
import {
assertExternalModelConfiguration,
createDefaultExternalModelConfiguration,
} from "../src/external-model/configuration/external-model-configuration.js";
const deviceTypeId = "10000000-0000-4000-8000-000000000001";
const ruleId = "20000000-0000-4000-8000-000000000001";
function configuration() {
const value = createDefaultExternalModelConfiguration();
value.deviceTypes.push({
id: deviceTypeId,
name: "Steckdose",
category: "single_phase",
defaults: { powerPerUnitW: 200, simultaneityFactor: null, cosPhi: 0.95, costGroup: "440" },
});
value.familyTypeRules.push({
id: ruleId,
exactFamilyAndType: "Steckdose: Standard",
deviceTypeId,
quantityRule: "socketCount",
});
return value;
}
function snapshot(projectId = "project-1") {
return { id: "config-1", projectId, configuration: configuration() };
}
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
return context;
}
describe("external model configuration", () => {
it("validates the single Worksharing-ID mapping and exact family rules", () => {
const value = configuration();
assert.doesNotThrow(() => assertExternalModelConfiguration(value));
assert.equal("idColumn" in value.layout, false);
const duplicate = structuredClone(value);
duplicate.columns.ifcGuid = duplicate.columns.worksharingId;
assert.throws(() => assertExternalModelConfiguration(duplicate), /nicht eindeutig/);
const unknownType = structuredClone(value);
unknownType.familyTypeRules[0]!.deviceTypeId = "30000000-0000-4000-8000-000000000001";
assert.throws(() => assertExternalModelConfiguration(unknownType), /unbekannte Ger.*teart/);
});
it("persists changes without an independent configuration revision", () => {
const context = createTestDatabase();
try {
const repository = new ExternalModelConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(null, initial),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.equal("configurationVersion" in initial, false);
const target = structuredClone(initial);
target.configuration.deviceTypes[0]!.name = "Steckdose allgemein";
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
});
assert.equal(updated.revision.revisionNumber, 2);
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(initial, target),
}),
/zwischenzeitlich ge.*ndert/
);
} finally {
context.close();
}
});
it("distinguishes missing projects and remaps configuration identities on duplicate", () => {
const context = createTestDatabase();
try {
const reader = new ExternalModelConfigurationRepository(context.db);
assert.deepEqual(reader.getByProject("project-1"), { projectExists: true, configuration: null });
assert.deepEqual(reader.getByProject("missing"), { projectExists: false, configuration: null });
new ExternalModelConfigurationProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalModelConfigurationUpdateProjectCommand(null, snapshot()),
});
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const copied = context.db.select().from(externalModelConfigurations)
.where(eq(externalModelConfigurations.projectId, duplicate.projectId)).get();
assert.ok(copied);
assert.notEqual(copied.id, "config-1");
assert.notEqual(copied.configuration.deviceTypes[0]!.id, deviceTypeId);
assert.equal(
copied.configuration.familyTypeRules[0]!.deviceTypeId,
copied.configuration.deviceTypes[0]!.id
);
} finally {
context.close();
}
});
});

View file

@ -1,431 +0,0 @@
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();
}

View file

@ -0,0 +1,87 @@
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 { parseExternalModelStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
const lowerId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff";
const upperId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-FFFFFFFF";
function fullBatch(id: string, importedAtIso: string) {
return {
id,
projectId: "p",
sourceId: "s",
importKind: "follow_up" as const,
importedAtIso,
fileName: `${id}.xlsx`,
sha256: "a".repeat(64),
objectCount: 1,
appliedProjectRevision: 3,
configurationSnapshot: createDefaultExternalModelConfiguration(),
originalContentBase64: "AA==",
document: {
sheetName: "Bauteilliste",
headerRowNumber: 1,
firstDataRowNumber: 3,
firstDataColumnNumber: 1,
columns: [],
rows: [],
issues: [],
},
};
}
describe("Linear XLSX external model foundation", () => {
it("keeps only the newest batch complete", () => {
const newest = fullBatch("new", "2026-02-01T00:00:00.000Z");
const older = {
...fullBatch("old", "2026-01-01T00:00:00.000Z"),
appliedProjectRevision: null,
configurationSnapshot: null,
originalContentBase64: null,
document: null,
};
assert.doesNotThrow(() => parseExternalModelStateSnapshot({
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
importBatches: [older, newest],
roomMappings: [],
objects: [],
}));
assert.throws(() => parseExternalModelStateSnapshot({
source: { id: "s", projectId: "p", name: "Revit", sourceType: "revit_xlsx" },
importBatches: [newest, fullBatch("newer", "2026-03-01T00:00:00.000Z")],
roomMappings: [],
objects: [],
}), /Nur der letzte Importbatch/);
});
it("compares Worksharing-IDs exactly and allows duplicate IfcGUID values", () => {
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
context.sqlite.exec(`
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
INSERT INTO external_model_sources (id, project_id, name) VALUES ('s', 'p', 'Revit');
INSERT INTO external_import_batches
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, object_count)
VALUES ('b', 'p', 's', 'initial', '2026-01-01T00:00:00.000Z', 'model.xlsx', '${"a".repeat(64)}', 2);
`);
const insert = context.sqlite.prepare(`
INSERT INTO external_model_objects
(id, project_id, source_id, worksharing_id, ifc_guid, device_type_id,
last_seen_import_batch_id, last_accepted_import_batch_id,
accepted_source_values, planning_values, overridden_fields)
VALUES (?, 'p', 's', ?, 'same-IfcGUID', 'type', 'b', 'b', '{}', '{}', '[]')
`);
insert.run("o1", lowerId);
insert.run("o2", upperId);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 2);
assert.throws(() => insert.run("o3", lowerId), /UNIQUE constraint failed/);
} finally {
context.close();
}
});
});

View file

@ -31,7 +31,6 @@ import {
externalObjectAssignToNewCircuitCommandType,
} from "../src/domain/models/external-object-new-circuit-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:");
@ -63,20 +62,16 @@ function createFixture(initialObjectRowId: string | null = "row-1") {
simultaneityFactor: 1,
}).run();
}
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_csv" }).run();
database.insert(externalModelSources).values({ id: "source-1", projectId: "project-1", name: "Revit", sourceType: "revit_xlsx" }).run();
database.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
fileName: "revit.xlsx",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
objectCount: 1,
}).run();
database.insert(externalRoomMappings).values({
id: "mapping-1",
@ -98,7 +93,9 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
ifcGuid: "ifc-1",
deviceTypeId: "device-type-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
@ -107,14 +104,18 @@ function objectSnapshot(circuitDeviceRowId: string | null): ExternalModelObjectS
roomName: "Büro",
familyAndType: "Steckdose: Standard",
selectionMarker: "Steckdose",
socketCount: "2",
sourceRemark: null,
circuitCombined: "-2F1.1",
boardName: "UV 1",
circuitIdentifier: "-2F1.1",
power: "120",
quantity: "2",
additionalSourceValues: {},
designation: null,
electricalPowerW: "120",
voltageV: 230,
currentA: null,
},
planningValues: {
displayName: "Steckdose",
internalDeviceType: "socket",
category: "single_phase",
connectionKind: "socket",
effectiveQuantity: 2,

View file

@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { it } from "node:test";
import { spawnSync } from "node:child_process";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
it("replaces legacy CSV state and resets linked row quantities to their manual share", () => {
const source = path.resolve("src", "db", "migrations");
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-"));
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
mkdirSync(path.join(staging, "meta"));
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) {
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
}
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: staging });
context.sqlite.exec(`
INSERT INTO projects (id, name) VALUES ('p', 'Projekt');
INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV');
INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste');
INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.');
INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1');
INSERT INTO circuit_device_rows
(id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor)
VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1);
INSERT INTO external_csv_configurations
(id, project_id, configuration_version, configuration)
VALUES ('cfg', 'p', 3, '{}');
INSERT INTO external_model_sources (id, project_id, name, source_type)
VALUES ('src', 'p', 'Revit', 'revit_csv');
INSERT INTO external_import_batches
(id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256,
applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version)
VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3);
INSERT INTO external_model_objects
(id, project_id, source_id, ifc_guid, last_seen_import_batch_id,
last_accepted_import_batch_id, accepted_source_values, planning_values,
overridden_fields, circuit_device_row_id)
VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r');
`);
migrate(context.db, { migrationsFolder: source });
assert.equal(
(context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity,
2
);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0);
assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/);
const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>;
assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1);
assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
} finally {
context.close();
rmSync(staging, { recursive: true, force: true });
}
});
it("creates and verifies a backup before the Linear-XLSX migration", () => {
const source = path.resolve("src", "db", "migrations");
const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-"));
const staging = path.join(root, "migrations");
const dataDirectory = path.join(root, "data");
mkdirSync(path.join(staging, "meta"), { recursive: true });
mkdirSync(dataDirectory, { recursive: true });
const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13);
writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal));
for (const entry of journal.entries) {
copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`));
}
const databasePath = path.join(dataDirectory, "leistungsbilanz.db");
const context = createDatabaseContext(databasePath);
try {
migrate(context.db, { migrationsFolder: staging });
} finally {
context.close();
}
try {
const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], {
cwd: path.resolve("."),
env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory },
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /Verified pre-migration backup created/);
const backups = readdirSync(path.join(dataDirectory, "backups"));
assert.equal(backups.length, 1);
const migrated = createDatabaseContext(databasePath);
try {
assert.ok(migrated.sqlite.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'"
).get());
} finally {
migrated.close();
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});

View file

@ -30,7 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalModelConfigurationProjectCommandRepository } from "../src/db/repositories/external-model-configuration-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
@ -44,7 +44,7 @@ import { projects } from "../src/db/schema/projects.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
@ -90,9 +90,8 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { createExternalModelConfigurationUpdateProjectCommand } from "../src/domain/models/external-model-configuration-project-command.model.js";
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@ -166,7 +165,7 @@ function createService(context: DatabaseContext) {
new CircuitGroupSubtreeProjectCommandRepository(context.db),
new CircuitProtectionProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db),
new ExternalCsvConfigurationProjectCommandRepository(context.db)
new ExternalModelConfigurationProjectCommandRepository(context.db)
);
}
@ -1127,31 +1126,62 @@ describe("project command service", () => {
}
});
it("dispatches external CSV configuration updates through persistent history", () => {
it("dispatches external XLSX configuration updates through persistent history", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const target = {
id: "external-config-1",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
configuration: createDefaultExternalModelConfiguration(),
};
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createExternalCsvConfigurationUpdateProjectCommand(null, target),
command: createExternalModelConfigurationUpdateProjectCommand(null, target),
});
assert.equal(updated.history.currentRevision, 1);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
assert.equal(undone.history.currentRevision, 2);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0);
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 0);
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
assert.equal(redone.history.currentRevision, 3);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
} finally {
context.close();
}
});
it("rejects legacy CSV commands stored in project history with a clear error", () => {
const context = createTestDatabase();
try {
const service = createService(context);
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createExternalModelConfigurationUpdateProjectCommand(null, {
id: "external-config-1",
projectId: "project-1",
configuration: createDefaultExternalModelConfiguration(),
}),
});
context.db.update(projectChangeSets).set({
inversePayloadJson: JSON.stringify({
schemaVersion: 1,
type: "external-import.apply-initial",
payload: {},
}),
}).run();
assert.throws(
() => service.undo({ projectId: "project-1", expectedRevision: 1 }),
/alten Revit-CSV-Anbindung/
);
assert.equal(context.db.select().from(externalModelConfigurations).all().length, 1);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
} finally {
context.close();
}

View file

@ -19,7 +19,6 @@ import { externalModelSources } from "../src/db/schema/external-model-sources.js
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import {
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
@ -178,7 +177,7 @@ function linkExternalObject(
id: "source-1",
projectId: "project-1",
name: "Revit",
sourceType: "revit_csv",
sourceType: "revit_xlsx",
})
.run();
context.db
@ -189,13 +188,9 @@ function linkExternalObject(
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T16:00:00.000Z",
fileName: "revit.csv",
fileName: "revit.xlsx",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: externalCsvTestConfiguration,
originalBytes: Buffer.from("test"),
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
objectCount: 1,
})
.run();
context.db
@ -204,7 +199,9 @@ function linkExternalObject(
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff",
ifcGuid: "ifc-1",
deviceTypeId: "device-type-1",
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: {
@ -213,14 +210,18 @@ function linkExternalObject(
roomName: "Büro",
familyAndType: "Leuchte: Standard",
selectionMarker: "Leuchte",
socketCount: String(effectiveQuantity),
sourceRemark: null,
circuitCombined: "-1F1",
boardName: "UV 1",
circuitIdentifier: "-1F1",
power: "30",
quantity: String(effectiveQuantity),
additionalSourceValues: {},
designation: null,
electricalPowerW: "30",
voltageV: 230,
currentA: null,
},
planningValues: {
displayName: "Leuchte",
internalDeviceType: "luminaire",
category: "single_phase",
connectionKind: "fixed",
effectiveQuantity,

View file

@ -465,6 +465,8 @@ describe("project snapshot repository", () => {
rooms: Array<Record<string, unknown>>;
};
payload.schemaVersion = 5;
(payload as Record<string, unknown>).externalCsvConfiguration = null;
delete (payload as Record<string, unknown>).externalModelConfiguration;
delete payload.buildings;
for (const floor of payload.floors) {
delete floor.buildingId;

View file

@ -8,11 +8,16 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js";
import { remapProjectState } from "../src/domain/models/project-transfer.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
/** Removes the fields introduced by snapshot schema version 9. */
function withoutVersionNineFields<
T extends { distributionBoards: object[] },
>(snapshot: T): T {
const record = snapshot as Record<string, unknown>;
record.externalCsvConfiguration = null;
delete record.externalModelConfiguration;
for (const board of snapshot.distributionBoards) {
delete (board as Record<string, unknown>).feedingDistributionBoardId;
delete (board as Record<string, unknown>).feedingCircuitId;
@ -77,7 +82,7 @@ function minimalSnapshot() {
projectEditor: null as string | null,
},
distributionBoards: [],
externalCsvConfiguration: null,
externalModelConfiguration: null,
externalModel: {
source: null,
importBatches: [],
@ -315,6 +320,7 @@ describe("project state snapshot model", () => {
const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalModelConfiguration;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel;
@ -322,7 +328,7 @@ describe("project state snapshot model", () => {
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.isPublicBuilding, false);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.equal(upgraded.externalModelConfiguration, null);
});
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
@ -334,7 +340,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
@ -351,7 +357,7 @@ describe("project state snapshot model", () => {
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.equal(upgraded.externalModelConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
@ -360,6 +366,28 @@ describe("project state snapshot model", () => {
});
});
it("rejects legacy CSV configuration and imported state with a German migration error", () => {
const configured = withoutVersionNineFields(structuredClone(minimalSnapshot()));
configured.schemaVersion = 9 as typeof configured.schemaVersion;
configured.externalCsvConfiguration = {
id: "legacy",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
};
assert.throws(() => parseProjectStateSnapshot(configured), /Revit-CSV-Daten/);
const imported = withoutVersionNineFields(structuredClone(minimalSnapshot()));
imported.schemaVersion = 9 as typeof imported.schemaVersion;
imported.externalModel.source = {
id: "legacy-source",
projectId: "project-1",
name: "Legacy",
sourceType: "revit_csv",
};
assert.throws(() => parseProjectStateSnapshot(imported), /Revit-CSV-Daten/);
});
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 5 as typeof previous.schemaVersion;
@ -567,7 +595,7 @@ describe("project state snapshot model", () => {
});
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
const previous = structuredClone(current);
const previous = withoutVersionNineFields(structuredClone(current));
previous.schemaVersion = 7 as typeof previous.schemaVersion;
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
const upgraded = parseProjectStateSnapshot(previous);