Define Revit CSV contracts

This commit is contained in:
2026-08-02 15:51:52 +02:00
parent 927b868dcb
commit 43cee890e7
4 changed files with 409 additions and 4 deletions
@@ -2,9 +2,10 @@
## Status ## Status
Phase 14.0 ist abgeschlossen. Dieses Dokument beschreibt den gegen den aktuellen Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
Code geprüften Zielzuschnitt. Die Laufzeitimplementierung beginnt gemäß der gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in
verbindlichen Revit-Spezifikation erst nach Freigabe dieses Audits. Umsetzung; ihre reinen Transportverträge und Konfigurationsvalidatoren sind
vorhanden.
Fachliche Quelle bleibt Fachliche Quelle bleibt
[Revit-CSV-Integration Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md). [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 ### 14.1 CSV-Konfiguration und reine Vorschau
1. Transport-Domainverträge, Dialekt- und Mappingvalidatoren sowie kleine 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, 2. Zustandsfreien Parser und Serializer mit Round-trip-Tests für UTF-8-BOM,
CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie CRLF, Semikolon, vollständige Quotierung, Kopfzeile in Zeile 2 sowie
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren.
@@ -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<string>();
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<string>();
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<string>();
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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: [],
};
}
+112
View File
@@ -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<string, unknown>;
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;
}