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;
}