From da689af5182ee3b18f56b9d64c780fbd57af7ca9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sun, 2 Aug 2026 16:01:05 +0200 Subject: [PATCH] Verify Revit reference CSV --- .../spec/revit-csv-phase-14-audit-and-plan.md | 13 ++-- package.json | 3 +- scripts/verify-revit-reference-csv.ts | 73 +++++++++++++++++++ .../csv/external-csv-configuration.ts | 16 +++- .../csv/external-csv-contracts.ts | 2 +- .../csv/external-csv-transport.ts | 12 ++- tests/external-csv-configuration.test.ts | 19 +++++ tests/external-csv-transport.test.ts | 22 ++++++ 8 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 scripts/verify-revit-reference-csv.ts diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index 5db8f6a..ad84c28 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -253,9 +253,11 @@ keine Projektrevision und laufen über einen eigenen Audit-Store. geparst und ihr Hash gegen die Vorschau geprüft. Abnahme: fokussierte Tests, vollständige Tests und alle vorgeschriebenen -Build-/Typecheck-Schritte. Für die exakte Abnahme mit 897 Objekt- und 211 -Summenzeilen wird noch die anonymisierte Referenz-CSV benötigt; sie liegt nicht -im Repository. +Build-/Typecheck-Schritte. Die lokal bereitgestellte Referenz-CSV wird mit +`npm run revit:verify-reference` auf 897 Objektzeilen, 211 nichtleere +Passthrough-Summenzeilen und einen bytegleichen Round-trip geprüft. Da die +Referenzdatei reale Modelldaten enthalten kann, wird sie nicht automatisch in +einen Commit aufgenommen. ### 14.2 – Persistente Objekte und Erstimport @@ -322,8 +324,9 @@ festgelegt. nicht durch indirekte oder nachträglich neu berechnete Payloads optimiert. - **BMK-Drift:** Neue BMKs werden einmal bei der Planung berechnet und im Command gespeichert; Import, Sortierung und Redo nummerieren nie implizit. -- **Unvollständige Referenzabnahme:** Die reale beziehungsweise anonymisierte - Referenz-CSV muss vor Abschluss von 14.1 als lokale Testgrundlage vorliegen. +- **Vertrauliche Referenzdaten:** Der Referenzprüfer läuft lokal gegen die + bereitgestellte CSV. Vor einer Aufnahme dieser Datei in Git ist gesondert zu + klären, ob sie ausreichend anonymisiert und zur Weitergabe freigegeben ist. ## Freigabepunkt diff --git a/package.json b/package.json index 7f61d4d..9bc5ae3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", - "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js" + "db:verify:circuit-schema": "node scripts/db-verify-circuit-schema.js", + "revit:verify-reference": "tsx scripts/verify-revit-reference-csv.ts" }, "keywords": [], "author": "", diff --git a/scripts/verify-revit-reference-csv.ts b/scripts/verify-revit-reference-csv.ts new file mode 100644 index 0000000..7d4f047 --- /dev/null +++ b/scripts/verify-revit-reference-csv.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js"; +import { + parseExternalCsv, + serializeExternalCsv, +} from "../src/external-model/csv/external-csv-transport.js"; + +const filePath = resolve( + process.argv[2] ?? "docs/spec/ELT Stromkreisnummernvergabe-Check_DIV.csv" +); +const source = readFileSync(filePath); +const configuration = createDefaultExternalCsvConfiguration({ + ifcGuid: "IfcGUID", + roomNumber: "MEP-Raum: Nummer", + roomName: "MEP-Raum: Name", + familyAndType: "Familie und Typ", + selectionMarker: "CAx_Auswahlkenner", + circuitIdentifier: "kbp_Stromkreisnummer", + power: "kbp-E-Elektrische Leistung", + quantity: null, +}); +configuration.additionalSourceMappings = [ + { sourceColumn: "CAx_Anmerkung", targetField: "sourceRemark" }, + { sourceColumn: "kbp-E-Spannung", targetField: "sourceVoltage" }, + { sourceColumn: "kbp-E-Stromstärke", targetField: "sourceCurrent" }, + { sourceColumn: "kbp-E-Versorgung von ELT", targetField: "sourceElectricalSupply" }, + { sourceColumn: "kbp-E-Versorgung von MSR/GLT", targetField: "sourceControlSupply" }, +]; + +const document = parseExternalCsv(source, configuration); +const counts = Object.fromEntries( + ["metadata", "header", "passthrough", "object", "suspect-object"].map( + (classification) => [ + classification, + document.rows.filter((row) => row.classification === classification).length, + ] + ) +); +const nonEmptyPassthroughRows = document.rows.filter( + (row) => + row.classification === "passthrough" && + row.cells.some((cell) => cell.value !== "") +).length; +const serialized = serializeExternalCsv(document); + +assert.equal(counts.object, 897, "Expected 897 object rows."); +assert.equal(nonEmptyPassthroughRows, 211, "Expected 211 non-empty passthrough rows."); +assert.equal(counts["suspect-object"], 0, "Expected no suspect object rows."); +assert.equal( + Buffer.compare(Buffer.from(serialized), source), + 0, + "Expected a byte-identical CSV round-trip." +); + +const sha256 = createHash("sha256").update(source).digest("hex"); +process.stdout.write( + `${JSON.stringify( + { + file: filePath, + bytes: source.length, + sha256, + dialect: document.dialect, + rows: counts, + nonEmptyPassthroughRows, + roundTripByteIdentical: true, + }, + null, + 2 + )}\n` +); diff --git a/src/external-model/csv/external-csv-configuration.ts b/src/external-model/csv/external-csv-configuration.ts index 83e1c24..a06d7cf 100644 --- a/src/external-model/csv/external-csv-configuration.ts +++ b/src/external-model/csv/external-csv-configuration.ts @@ -19,7 +19,6 @@ const requiredColumnKeys = [ "selectionMarker", "circuitIdentifier", "power", - "quantity", ] as const; export function assertExternalCsvConfiguration( @@ -56,6 +55,16 @@ export function assertExternalCsvConfiguration( } mappedColumns.add(columnName); } + if (value.columns.quantity !== null) { + const quantityColumn = assertTrimmedString( + value.columns.quantity, + "columns.quantity" + ); + if (mappedColumns.has(quantityColumn)) { + throw new Error(`External CSV column is mapped more than once: ${quantityColumn}`); + } + mappedColumns.add(quantityColumn); + } if (!Array.isArray(value.additionalSourceMappings)) { throw new Error("External CSV additional source mappings must be an array."); @@ -115,6 +124,11 @@ export function assertExternalCsvConfiguration( throw new Error(`familyTypeRules.${index}.category is unsupported.`); } assertQuantityRule(rule.quantityRule, index); + if (rule.quantityRule.kind === "mapped-column" && value.columns.quantity === null) { + throw new Error( + `familyTypeRules.${index}.quantityRule requires a configured quantity column.` + ); + } assertDisplayNameSuggestion(rule.displayNameSuggestion, index); } } diff --git a/src/external-model/csv/external-csv-contracts.ts b/src/external-model/csv/external-csv-contracts.ts index e2d033c..8e797c9 100644 --- a/src/external-model/csv/external-csv-contracts.ts +++ b/src/external-model/csv/external-csv-contracts.ts @@ -15,7 +15,7 @@ export interface ExternalCsvColumnMapping { selectionMarker: string; circuitIdentifier: string; power: string; - quantity: string; + quantity: string | null; } export interface ExternalCsvAdditionalSourceMapping { diff --git a/src/external-model/csv/external-csv-transport.ts b/src/external-model/csv/external-csv-transport.ts index d8131a0..3446d24 100644 --- a/src/external-model/csv/external-csv-transport.ts +++ b/src/external-model/csv/external-csv-transport.ts @@ -59,7 +59,9 @@ export function parseExternalCsv( const headerRowIndex = headerMatches[0]; const configuredColumnNames = [ - ...Object.values(configuration.columns), + ...Object.values(configuration.columns).filter( + (column): column is string => column !== null + ), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ]; const headerValues = parsed.rows[headerRowIndex].map((cell) => cell.value); @@ -81,7 +83,9 @@ export function parseExternalCsv( configuration.columns.familyAndType, configuration.columns.selectionMarker, configuration.columns.power, - configuration.columns.quantity, + ...(configuration.columns.quantity === null + ? [] + : [configuration.columns.quantity]), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ].map((column) => headerLookup.get(column)!); const seenIfcGuids = new Set(); @@ -274,7 +278,9 @@ function findHeaderRows( configuration: ExternalCsvConfiguration ) { const requiredColumns = new Set([ - ...Object.values(configuration.columns), + ...Object.values(configuration.columns).filter( + (column): column is string => column !== null + ), ...configuration.additionalSourceMappings.map((mapping) => mapping.sourceColumn), ]); const matches: number[] = []; diff --git a/tests/external-csv-configuration.test.ts b/tests/external-csv-configuration.test.ts index 19411ee..47143a7 100644 --- a/tests/external-csv-configuration.test.ts +++ b/tests/external-csv-configuration.test.ts @@ -103,6 +103,25 @@ describe("external CSV configuration", () => { }); assert.match(validateFailure(invalidQuantity), /positive finite number/); }); + + it("allows a missing quantity column only for fixed quantity rules", () => { + const configuration = createDefaultExternalCsvConfiguration({ + ...columns, + quantity: null, + }); + configuration.familyTypeRules.push({ + exactFamilyAndType: "Steckdose: Standard", + internalDeviceType: "Steckdose", + connectionKind: null, + category: "single_phase", + quantityRule: { kind: "fixed", quantity: 1 }, + displayNameSuggestion: null, + }); + assert.equal(validateExternalCsvConfiguration(configuration).success, true); + + configuration.familyTypeRules[0].quantityRule = { kind: "mapped-column" }; + assert.match(validateFailure(configuration), /requires a configured quantity column/); + }); }); function validateFailure(value: unknown) { diff --git a/tests/external-csv-transport.test.ts b/tests/external-csv-transport.test.ts index 8d69474..11a44ac 100644 --- a/tests/external-csv-transport.test.ts +++ b/tests/external-csv-transport.test.ts @@ -51,6 +51,28 @@ describe("external CSV transport", () => { 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"',