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