Add Revit CSV transport

This commit is contained in:
2026-08-02 15:56:02 +02:00
parent 43cee890e7
commit bfe8b790b2
4 changed files with 521 additions and 5 deletions
+124
View File
@@ -0,0 +1,124 @@
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("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
);
}
+42
View File
@@ -0,0 +1,42 @@
import {
createDefaultExternalCsvConfiguration,
type ExternalCsvColumnMapping,
} from "../../src/external-model/csv/external-csv-contracts.js";
export const externalCsvTestColumns: ExternalCsvColumnMapping = {
ifcGuid: "IfcGUID",
roomNumber: "Raumnummer",
roomName: "Raumname",
familyAndType: "Familie und Typ",
selectionMarker: "CAx_Auswahlkenner",
circuitIdentifier: "Stromkreis",
power: "Elektrische Leistung",
quantity: "Anzahl",
};
export const externalCsvTestConfiguration =
createDefaultExternalCsvConfiguration(externalCsvTestColumns);
export const firstIfcGuid = "0AbCdEfGhIjKlMnOpQrStu";
export const secondIfcGuid = "1AbCdEfGhIjKlMnOpQrStu";
export const quotedRevitCsv = [
'"Projekt-Export";"";"";"";"";"";"";""',
'"Stromkreis";"Raumnummer";"Raumname";"Familie und Typ";"CAx_Auswahlkenner";"Elektrische Leistung";"Anzahl";"IfcGUID"',
'"";"";"";"";"";"";"";""',
`"UV_AV_01-2F1";"01/101";"Technik";"Steckdose: Doppelsteckdose";"Arbeitsplatz";"120,5";"2";"${firstIfcGuid}"`,
'"UV_AV_01-2F1: 2";"";"";"";"";"";"";""',
'"";"01/102";"Büro";"Steckdose: Standard";"Arbeitsplatz";"100";"1";""',
`"";"01/103";"Büro \"\"Nord\"\"";"Steckdose: Standard";"Arbeitsplatz";"100";"1";"${secondIfcGuid}"`,
].join("\r\n") + "\r\n";
export function utf8Bytes(value: string, withBom = false) {
const encoded = new TextEncoder().encode(value);
if (!withBom) {
return encoded;
}
const result = new Uint8Array(encoded.length + 3);
result.set([0xef, 0xbb, 0xbf]);
result.set(encoded, 3);
return result;
}