Define Revit CSV contracts
This commit is contained in:
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user