forked from jappel/leistungsbilanz-ts
Replace Revit CSV backend with Linear XLSX
This commit is contained in:
parent
52d3b1a270
commit
1cfeb06ec7
65 changed files with 4764 additions and 3717 deletions
|
|
@ -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: [] }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
132
tests/external-model-configuration.test.ts
Normal file
132
tests/external-model-configuration.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
87
tests/external-model-xlsx-foundation.test.ts
Normal file
87
tests/external-model-xlsx-foundation.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
115
tests/linear-xlsx-external-model-migration.test.ts
Normal file
115
tests/linear-xlsx-external-model-migration.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue