Add Linear Excel transport
This commit is contained in:
parent
d4803a18d2
commit
52d3b1a270
10 changed files with 2131 additions and 83 deletions
267
tests/external-xlsx-transport.test.ts
Normal file
267
tests/external-xlsx-transport.test.ts
Normal 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
134
tests/fixtures/revit-xlsx-fixtures.ts
vendored
Normal 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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue