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 88ef0cb..8ce675b 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -2,9 +2,10 @@ ## Status -Phase 14.0 ist abgeschlossen. Dieses Dokument beschreibt den gegen den aktuellen -Code geprüften Zielzuschnitt. Die Laufzeitimplementierung beginnt gemäß der -verbindlichen Revit-Spezifikation erst nach Freigabe dieses Audits. +Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den +gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in +Umsetzung; ihre reinen Transportverträge und Konfigurationsvalidatoren sind +vorhanden. Fachliche Quelle bleibt [Revit-CSV-Integration – Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). @@ -240,7 +241,8 @@ keine Projektrevision und laufen über einen eigenen Audit-Store. ### 14.1 – CSV-Konfiguration und reine Vorschau 1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine - synthetische Fixtures ergänzen. + synthetische Fixtures ergänzen. **Erledigt:** Verträge und Validatoren; + Parser-Fixtures folgen gemeinsam mit dem Parser. 2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM, CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. diff --git a/src/external-model/csv/external-csv-configuration.ts b/src/external-model/csv/external-csv-configuration.ts new file mode 100644 index 0000000..83e1c24 --- /dev/null +++ b/src/external-model/csv/external-csv-configuration.ts @@ -0,0 +1,185 @@ +import { + externalCsvConfigurationSchemaVersion, + type ExternalCsvConfiguration, + type ExternalCsvDisplayNameSuggestion, + type ExternalCsvQuantityRule, +} from "./external-csv-contracts.js"; + +const supportedCategories = new Set([ + "lighting", + "single_phase", + "three_phase", +]); + +const requiredColumnKeys = [ + "ifcGuid", + "roomNumber", + "roomName", + "familyAndType", + "selectionMarker", + "circuitIdentifier", + "power", + "quantity", +] as const; + +export function assertExternalCsvConfiguration( + value: unknown +): asserts value is ExternalCsvConfiguration { + if (!isRecord(value)) { + throw new Error("External CSV configuration must be an object."); + } + if (value.schemaVersion !== externalCsvConfigurationSchemaVersion) { + throw new Error("Unsupported external CSV configuration schema version."); + } + if (value.encoding !== "utf-8") { + throw new Error("External CSV encoding must be utf-8."); + } + if (value.delimiter !== ";" && value.delimiter !== "," && value.delimiter !== "\t") { + throw new Error("External CSV delimiter is unsupported."); + } + if (value.decimalSeparator !== "," && value.decimalSeparator !== ".") { + throw new Error("External CSV decimal separator is unsupported."); + } + if (value.powerUnit !== "W" && value.powerUnit !== "kW") { + throw new Error("External CSV power unit is unsupported."); + } + assertPositiveFiniteNumber(value.wattsPerSourceUnit, "wattsPerSourceUnit"); + + if (!isRecord(value.columns)) { + throw new Error("External CSV columns must be an object."); + } + const mappedColumns = new Set(); + for (const key of requiredColumnKeys) { + const columnName = assertTrimmedString(value.columns[key], `columns.${key}`); + if (mappedColumns.has(columnName)) { + throw new Error(`External CSV column is mapped more than once: ${columnName}`); + } + mappedColumns.add(columnName); + } + + if (!Array.isArray(value.additionalSourceMappings)) { + throw new Error("External CSV additional source mappings must be an array."); + } + const targetFields = new Set(); + for (const [index, mapping] of value.additionalSourceMappings.entries()) { + if (!isRecord(mapping)) { + throw new Error(`additionalSourceMappings.${index} must be an object.`); + } + const sourceColumn = assertTrimmedString( + mapping.sourceColumn, + `additionalSourceMappings.${index}.sourceColumn` + ); + const targetField = assertTrimmedString( + mapping.targetField, + `additionalSourceMappings.${index}.targetField` + ); + if (mappedColumns.has(sourceColumn)) { + throw new Error(`External CSV column is mapped more than once: ${sourceColumn}`); + } + if (targetFields.has(targetField)) { + throw new Error(`External target field is mapped more than once: ${targetField}`); + } + mappedColumns.add(sourceColumn); + targetFields.add(targetField); + } + + if (!Array.isArray(value.familyTypeRules)) { + throw new Error("External CSV family/type rules must be an array."); + } + const exactFamilyValues = new Set(); + for (const [index, rule] of value.familyTypeRules.entries()) { + if (!isRecord(rule)) { + throw new Error(`familyTypeRules.${index} must be an object.`); + } + const exactFamilyAndType = assertTrimmedString( + rule.exactFamilyAndType, + `familyTypeRules.${index}.exactFamilyAndType` + ); + if (exactFamilyValues.has(exactFamilyAndType)) { + throw new Error( + `External CSV family/type rule is duplicated: ${exactFamilyAndType}` + ); + } + exactFamilyValues.add(exactFamilyAndType); + assertTrimmedString( + rule.internalDeviceType, + `familyTypeRules.${index}.internalDeviceType` + ); + if (rule.connectionKind !== null) { + assertTrimmedString( + rule.connectionKind, + `familyTypeRules.${index}.connectionKind` + ); + } + if (typeof rule.category !== "string" || !supportedCategories.has(rule.category)) { + throw new Error(`familyTypeRules.${index}.category is unsupported.`); + } + assertQuantityRule(rule.quantityRule, index); + assertDisplayNameSuggestion(rule.displayNameSuggestion, index); + } +} + +export function validateExternalCsvConfiguration( + value: unknown +): { success: true; data: ExternalCsvConfiguration } | { success: false; error: string } { + try { + assertExternalCsvConfiguration(value); + return { success: true, data: value }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Invalid external CSV configuration.", + }; + } +} + +function assertQuantityRule(value: unknown, index: number): asserts value is ExternalCsvQuantityRule { + if (!isRecord(value)) { + throw new Error(`familyTypeRules.${index}.quantityRule must be an object.`); + } + if (value.kind === "mapped-column") { + return; + } + if (value.kind === "fixed") { + assertPositiveFiniteNumber(value.quantity, `familyTypeRules.${index}.quantityRule.quantity`); + return; + } + throw new Error(`familyTypeRules.${index}.quantityRule is unsupported.`); +} + +function assertDisplayNameSuggestion( + value: unknown, + index: number +): asserts value is ExternalCsvDisplayNameSuggestion | null { + if (value === null) { + return; + } + if (!isRecord(value)) { + throw new Error(`familyTypeRules.${index}.displayNameSuggestion must be an object or null.`); + } + if (value.kind === "selection-marker" || value.kind === "family-and-type") { + return; + } + if (value.kind === "fixed") { + assertTrimmedString(value.value, `familyTypeRules.${index}.displayNameSuggestion.value`); + return; + } + throw new Error(`familyTypeRules.${index}.displayNameSuggestion is unsupported.`); +} + +function assertPositiveFiniteNumber(value: unknown, field: string) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`${field} must be a positive finite number.`); + } +} + +function assertTrimmedString(value: unknown, field: string) { + if (typeof value !== "string" || !value.trim() || value !== value.trim()) { + throw new Error(`${field} must be a non-empty trimmed string.`); + } + return value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/external-model/csv/external-csv-contracts.ts b/src/external-model/csv/external-csv-contracts.ts new file mode 100644 index 0000000..e2d033c --- /dev/null +++ b/src/external-model/csv/external-csv-contracts.ts @@ -0,0 +1,106 @@ +import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js"; + +export const externalCsvConfigurationSchemaVersion = 1 as const; + +export type ExternalCsvEncoding = "utf-8"; +export type ExternalCsvDelimiter = ";" | "," | "\t"; +export type ExternalCsvDecimalSeparator = "," | "."; +export type ExternalCsvPowerUnit = "W" | "kW"; + +export interface ExternalCsvColumnMapping { + ifcGuid: string; + roomNumber: string; + roomName: string; + familyAndType: string; + selectionMarker: string; + circuitIdentifier: string; + power: string; + quantity: string; +} + +export interface ExternalCsvAdditionalSourceMapping { + sourceColumn: string; + targetField: string; +} + +export type ExternalCsvQuantityRule = + | { kind: "fixed"; quantity: number } + | { kind: "mapped-column" }; + +export type ExternalCsvDisplayNameSuggestion = + | { kind: "fixed"; value: string } + | { kind: "selection-marker" } + | { kind: "family-and-type" }; + +export interface ExternalCsvFamilyTypeRule { + exactFamilyAndType: string; + internalDeviceType: string; + connectionKind: string | null; + category: CircuitGroupCategory; + quantityRule: ExternalCsvQuantityRule; + displayNameSuggestion: ExternalCsvDisplayNameSuggestion | null; +} + +export interface ExternalCsvConfiguration { + schemaVersion: typeof externalCsvConfigurationSchemaVersion; + encoding: ExternalCsvEncoding; + delimiter: ExternalCsvDelimiter; + decimalSeparator: ExternalCsvDecimalSeparator; + powerUnit: ExternalCsvPowerUnit; + wattsPerSourceUnit: number; + columns: ExternalCsvColumnMapping; + additionalSourceMappings: ExternalCsvAdditionalSourceMapping[]; + familyTypeRules: ExternalCsvFamilyTypeRule[]; +} + +export type ExternalCsvLineEnding = "\r\n" | "\n" | "\r"; + +export interface ExternalCsvDialect { + encoding: ExternalCsvEncoding; + hasBom: boolean; + delimiter: ExternalCsvDelimiter; + lineEnding: ExternalCsvLineEnding; + quoteCharacter: '"'; + quoteAllFields: boolean; + hasTrailingLineEnding: boolean; +} + +export interface ExternalCsvCell { + value: string; + wasQuoted: boolean; +} + +export type ExternalCsvRowClassification = + | "metadata" + | "header" + | "passthrough" + | "object" + | "suspect-object"; + +export interface ExternalCsvRow { + index: number; + cells: ExternalCsvCell[]; + classification: ExternalCsvRowClassification; +} + +export interface ExternalCsvDocument { + dialect: ExternalCsvDialect; + headerRowIndex: number; + rows: ExternalCsvRow[]; +} + +export function createDefaultExternalCsvConfiguration( + columns: ExternalCsvColumnMapping +): ExternalCsvConfiguration { + return { + schemaVersion: externalCsvConfigurationSchemaVersion, + encoding: "utf-8", + delimiter: ";", + decimalSeparator: ",", + powerUnit: "W", + wattsPerSourceUnit: 1, + columns, + additionalSourceMappings: [], + familyTypeRules: [], + }; +} diff --git a/tests/external-csv-configuration.test.ts b/tests/external-csv-configuration.test.ts new file mode 100644 index 0000000..19411ee --- /dev/null +++ b/tests/external-csv-configuration.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createDefaultExternalCsvConfiguration, + type ExternalCsvColumnMapping, +} from "../src/external-model/csv/external-csv-contracts.js"; +import { + assertExternalCsvConfiguration, + validateExternalCsvConfiguration, +} from "../src/external-model/csv/external-csv-configuration.js"; + +const columns: ExternalCsvColumnMapping = { + ifcGuid: "IfcGUID", + roomNumber: "Raumnummer", + roomName: "Raumname", + familyAndType: "Familie und Typ", + selectionMarker: "CAx_Auswahlkenner", + circuitIdentifier: "Stromkreis", + power: "Elektrische Leistung", + quantity: "Anzahl", +}; + +describe("external CSV configuration", () => { + it("creates the agreed UTF-8, semicolon and watt defaults", () => { + const configuration = createDefaultExternalCsvConfiguration(columns); + + assert.doesNotThrow(() => assertExternalCsvConfiguration(configuration)); + assert.equal(configuration.encoding, "utf-8"); + assert.equal(configuration.delimiter, ";"); + assert.equal(configuration.powerUnit, "W"); + assert.equal(configuration.wattsPerSourceUnit, 1); + }); + + it("accepts exact family/type rules and additional source mappings", () => { + const configuration = createDefaultExternalCsvConfiguration(columns); + configuration.additionalSourceMappings.push({ + sourceColumn: "Montageart", + targetField: "mountingType", + }); + configuration.familyTypeRules.push({ + exactFamilyAndType: "_E_CAx Mehrfachsteckdose: Doppelsteckdose", + internalDeviceType: "Steckdose", + connectionKind: "socket", + category: "single_phase", + quantityRule: { kind: "mapped-column" }, + displayNameSuggestion: { kind: "selection-marker" }, + }); + + assert.deepEqual(validateExternalCsvConfiguration(configuration), { + success: true, + data: configuration, + }); + }); + + it("rejects ambiguous column and family/type mappings", () => { + const duplicateColumn = createDefaultExternalCsvConfiguration({ + ...columns, + quantity: columns.power, + }); + assert.match( + validateFailure(duplicateColumn), + /column is mapped more than once/ + ); + + const duplicateRule = createDefaultExternalCsvConfiguration(columns); + duplicateRule.familyTypeRules = [ + { + exactFamilyAndType: "Leuchte: Standard", + internalDeviceType: "Leuchte", + connectionKind: null, + category: "lighting", + quantityRule: { kind: "fixed", quantity: 1 }, + displayNameSuggestion: null, + }, + { + exactFamilyAndType: "Leuchte: Standard", + internalDeviceType: "Leuchte", + connectionKind: null, + category: "lighting", + quantityRule: { kind: "fixed", quantity: 1 }, + displayNameSuggestion: null, + }, + ]; + assert.match( + validateFailure(duplicateRule), + /family\/type rule is duplicated/ + ); + }); + + it("rejects unsupported transport values and invalid fixed quantities", () => { + const configuration = createDefaultExternalCsvConfiguration(columns) as unknown as Record; + configuration.encoding = "ansi"; + assert.match(validateFailure(configuration), /encoding must be utf-8/); + + const invalidQuantity = createDefaultExternalCsvConfiguration(columns); + invalidQuantity.familyTypeRules.push({ + exactFamilyAndType: "Steckdose: Standard", + internalDeviceType: "Steckdose", + connectionKind: null, + category: "single_phase", + quantityRule: { kind: "fixed", quantity: 0 }, + displayNameSuggestion: { kind: "fixed", value: "Steckdose" }, + }); + assert.match(validateFailure(invalidQuantity), /positive finite number/); + }); +}); + +function validateFailure(value: unknown) { + const result = validateExternalCsvConfiguration(value); + assert.equal(result.success, false); + return result.error; +}