Add Linear Excel transport

This commit is contained in:
Julian Appel 2026-09-30 07:33:06 +02:00
parent d4803a18d2
commit 52d3b1a270
10 changed files with 2131 additions and 83 deletions

View file

@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import ExcelJS from "exceljs";
import {
createExternalXlsxReturn,
ExternalXlsxError,
parseExternalXlsx,
} from "../src/external-model/xlsx/external-xlsx-transport.js";
import {
createRevitXlsxFixture,
externalXlsxTestConfiguration,
firstWorksharingId,
secondWorksharingId,
} from "./fixtures/revit-xlsx-fixtures.js";
describe("external XLSX transport", () => {
it("reads typed cells and classifies ID-less rows without blocking summaries", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(),
configuration: externalXlsxTestConfiguration,
});
assert.equal(document.sheetName, "Linear Export");
assert.equal(document.headerRowNumber, 1);
assert.equal(document.firstDataRowNumber, 3);
assert.equal(document.firstDataColumnNumber, 1);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
assert.equal(document.rows[0].cells[5].value, 2);
assert.equal(document.rows[0].cells[15].value, true);
assert.equal(document.columns[0].comment, "Kommentar Worksharing-ID");
assert.equal(document.columns[0].width, 12);
assert.deepEqual(
document.issues.map((issue) => issue.code),
["suspect-object-skipped"]
);
});
it("resolves editable layout positions without a separate ID column", async () => {
const configuration = {
...externalXlsxTestConfiguration,
layout: { headerRow: 2, firstDataRow: 5, firstDataColumn: 2 },
};
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture(configuration.layout),
configuration,
});
assert.equal(document.headerRowNumber, 2);
assert.equal(document.firstDataRowNumber, 5);
assert.equal(document.firstDataColumnNumber, 2);
assert.equal(document.columns[1].header, "Worksharing-ID");
assert.equal(document.rows[0].cells[1].value, firstWorksharingId);
assert.deepEqual(
document.rows.map((row) => row.classification),
["object", "object", "suspect-object", "passthrough"]
);
});
it("compares Worksharing-ID and IfcGUID exactly and case-sensitively", async () => {
const lowerCaseId = secondWorksharingId.toLowerCase();
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
secondWorksharingId: lowerCaseId,
firstIfcGuid: "IfcGUID",
secondIfcGuid: "IFCGUID",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
document.rows.filter((row) => row.classification === "object").length,
2
);
assert.equal(
document.issues.some((issue) => issue.code === "duplicate-ifc-guid"),
false
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ duplicateWorksharingId: true }),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("duplicate-worksharing-id")
);
const duplicateIfcDocument = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
firstIfcGuid: "same",
secondIfcGuid: "same",
}),
configuration: externalXlsxTestConfiguration,
});
assert.equal(
duplicateIfcDocument.issues.filter(
(issue) => issue.code === "duplicate-ifc-guid"
).length,
1
);
});
it("uses cached formula values without evaluating formulas", async () => {
const document = await parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({ includeFormula: true }),
configuration: externalXlsxTestConfiguration,
});
const powerCell = document.rows[0].cells[11];
assert.equal(powerCell.value, 250);
assert.equal(powerCell.formula, "125*2");
assert.equal(powerCell.usedCachedFormulaValue, true);
assert.equal(
document.issues.some(
(issue) => issue.code === "cached-formula-value-used"
),
true
);
});
it("blocks a required formula without a cached result", async () => {
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: await createRevitXlsxFixture({
includeRequiredFormulaWithoutResult: true,
}),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("required-formula-result-missing")
);
});
it("writes selected object rows in source order and preserves workbook structure", async () => {
const source = await createRevitXlsxFixture({ includeFormula: true });
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
selectedWorksharingIds: [secondWorksharingId, firstWorksharingId],
updates: [
{
worksharingId: firstWorksharingId,
values: {
circuitCombined: "UV99-3F7",
electricalPowerW: 321.5,
voltageV: 400,
currentA: 0.46,
},
},
],
});
assert.equal(result.exportedObjectCount, 2);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(result.bytes as never);
const worksheet = workbook.worksheets[0];
assert.equal(worksheet.name, "Linear Export");
assert.equal(worksheet.rowCount, 4);
assert.equal(worksheet.getCell("A3").value, firstWorksharingId);
assert.equal(worksheet.getCell("A4").value, secondWorksharingId);
assert.equal(worksheet.getCell("H3").value, "UV99-3F7");
assert.equal(worksheet.getCell("L3").value, 321.5);
assert.equal(worksheet.getCell("M3").value, 400);
assert.equal(worksheet.getCell("N3").value, 0.46);
assert.equal(worksheet.getCell("P4").value, "bleibt");
assert.equal(worksheet.getCell("L3").numFmt, "0.00");
assert.equal(worksheet.getColumn(1).width, 12);
assert.equal(
typeof worksheet.getCell("A1").note === "string"
? worksheet.getCell("A1").note
: worksheet.getCell("A1").note?.texts?.[0]?.text,
"Kommentar Worksharing-ID"
);
const commentedCells: string[] = [];
worksheet.eachRow({ includeEmpty: true }, (row) => {
row.eachCell({ includeEmpty: true }, (cell) => {
if (cell.note !== undefined) commentedCells.push(cell.address);
});
});
assert.deepEqual(
commentedCells,
Array.from({ length: 16 }, (_, index) =>
worksheet.getCell(1, index + 1).address
)
);
});
it("warns for missing optional return columns and blocks missing circuit columns", async () => {
const source = await createRevitXlsxFixture();
const optionalMissing = {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
designation: "Nicht vorhanden",
},
};
const result = await createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: optionalMissing,
});
assert.equal(
result.issues.some(
(issue) =>
issue.code === "optional-return-column-missing" &&
issue.field === "designation"
),
true
);
await assert.rejects(
createExternalXlsxReturn({
fileName: "linear.xlsx",
bytes: source,
configuration: {
...externalXlsxTestConfiguration,
columns: {
...externalXlsxTestConfiguration.columns,
circuitCombined: "Nicht vorhanden",
},
},
}),
isXlsxError("return-circuit-column-missing")
);
});
it("enforces extension, byte size and post-read sheet limits", async () => {
const source = await createRevitXlsxFixture();
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsm",
bytes: source,
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("invalid-file-extension")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: new Uint8Array(10 * 1024 * 1024 + 1),
configuration: externalXlsxTestConfiguration,
}),
isXlsxError("file-too-large")
);
await assert.rejects(
parseExternalXlsx({
fileName: "linear.xlsx",
bytes: source,
configuration: externalXlsxTestConfiguration,
limits: { maxRows: 3, maxColumns: 100, maxCells: 1_000 },
}),
isXlsxError("worksheet-too-large")
);
});
});
function isXlsxError(code: ExternalXlsxError["code"]) {
return (error: unknown) =>
error instanceof ExternalXlsxError && error.code === code;
}

134
tests/fixtures/revit-xlsx-fixtures.ts vendored Normal file
View file

@ -0,0 +1,134 @@
import ExcelJS from "exceljs";
import {
createDefaultExternalXlsxTransportConfiguration,
type ExternalXlsxTransportConfiguration,
} from "../../src/external-model/xlsx/external-xlsx-contracts.js";
export const firstWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000001";
export const secondWorksharingId =
"AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000002";
export const externalXlsxTestConfiguration: ExternalXlsxTransportConfiguration =
createDefaultExternalXlsxTransportConfiguration();
export async function createRevitXlsxFixture(options?: {
duplicateWorksharingId?: boolean;
secondWorksharingId?: string;
firstIfcGuid?: string;
secondIfcGuid?: string;
includeFormula?: boolean;
includeRequiredFormulaWithoutResult?: boolean;
headerRow?: number;
firstDataRow?: number;
firstDataColumn?: number;
}) {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Linear Export");
const headerRow = options?.headerRow ?? 1;
const firstDataRow = options?.firstDataRow ?? 3;
const firstDataColumn = options?.firstDataColumn ?? 1;
const headers = [
"Worksharing-ID",
"CAx_Auswahlkenner",
"MEP-Raum: Nummer",
"MEP-Raum: Name",
"Familie und Typ",
"CAX_AnzahlObjekte",
"CAx_Anmerkung",
"kbp_Stromkreisnummer",
"kbp-E-Verteiler",
"kbp-E-Stromkreis",
"kbp-E-Bezeichnung",
"kbp-E-Elektrische Leistung",
"kbp-E-Spannung",
"kbp-E-Stromstärke",
"IfcGUID",
"Unbekannt",
];
headers.forEach((header, index) => {
const columnNumber = firstDataColumn + index;
const cell = worksheet.getCell(headerRow, columnNumber);
cell.value = header;
cell.note = `Kommentar ${header}`;
cell.font = { bold: true };
worksheet.getColumn(columnNumber).width = 12 + index;
});
setRowValues(worksheet, firstDataRow, firstDataColumn, [
firstWorksharingId,
"Steckdose",
"01.101",
"Büro",
"Steckdose: Standard",
2,
"Quelle A",
"UV01-2F1",
"UV01",
"-2F1",
"Arbeitsplatz",
250,
230,
1.09,
options?.firstIfcGuid ?? "IfcCaseSensitive",
true,
]);
worksheet.getCell(firstDataRow, firstDataColumn + 11).numFmt = "0.00";
worksheet.getCell(firstDataRow, firstDataColumn + 12).numFmt = "0";
worksheet.getCell(firstDataRow, firstDataColumn + 13).numFmt = "0.00";
if (options?.includeFormula) {
worksheet.getCell(firstDataRow, firstDataColumn + 11).value = {
formula: "125*2",
result: 250,
};
}
if (options?.includeRequiredFormulaWithoutResult) {
worksheet.getCell(firstDataRow, firstDataColumn).value = { formula: "\"\"" };
}
setRowValues(worksheet, firstDataRow + 1, firstDataColumn, [
options?.duplicateWorksharingId
? firstWorksharingId
: options?.secondWorksharingId ?? secondWorksharingId,
"Leuchte",
null,
null,
"Leuchte: Decke",
1,
"Quelle B",
"UV01-1F1",
"UV01",
"-1F1",
"Beleuchtung",
42.5,
230,
0.18,
options?.secondIfcGuid ?? "IfcCaseSensitive2",
"bleibt",
]);
worksheet.getCell(firstDataRow + 1, firstDataColumn + 11).numFmt = "0.00";
setRowValues(worksheet, firstDataRow + 2, firstDataColumn, [
null,
"verdächtig",
null,
null,
"Gerät: Ohne ID",
]);
setRowValues(worksheet, firstDataRow + 3, firstDataColumn, [
null,
"Summe: 292,5 W",
]);
return new Uint8Array(await workbook.xlsx.writeBuffer());
}
function setRowValues(
worksheet: ExcelJS.Worksheet,
rowNumber: number,
firstColumn: number,
values: ExcelJS.CellValue[]
) {
values.forEach((value, index) => {
worksheet.getCell(rowNumber, firstColumn + index).value = value;
});
}