From 009152b4060a0b2a900017918f94751c785752f2 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 22 Jul 2026 20:40:07 +0200 Subject: [PATCH] Extract circuit grid model --- docs/circuit-list-editor-architecture.md | 9 + package.json | 4 +- .../components/circuit-tree-editor.tsx | 353 +----------------- src/frontend/utils/circuit-grid-model.ts | 283 ++++++++++++++ tests/circuit-grid-model.test.ts | 82 ++++ 5 files changed, 396 insertions(+), 335 deletions(-) create mode 100644 src/frontend/utils/circuit-grid-model.ts create mode 100644 tests/circuit-grid-model.test.ts diff --git a/docs/circuit-list-editor-architecture.md b/docs/circuit-list-editor-architecture.md index 7a090fc..b2038ef 100644 --- a/docs/circuit-list-editor-architecture.md +++ b/docs/circuit-list-editor-architecture.md @@ -4,6 +4,15 @@ The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits. +## Frontend Grid Modules + +- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration. +- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values. +- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions. +- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements. + +The pure grid modules have no React state and are covered by focused unit tests. This keeps circuit/device ownership rules testable while the editor UI is split incrementally. + ## Domain Model Overview - `CircuitSection` diff --git a/package.json b/package.json index 0b08f30..78c83d4 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts", - "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts", + "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts", + "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "node scripts/db-backup.js", diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index e55a940..803a5c1 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -15,6 +15,26 @@ import { requiresCrossSectionMoveConfirmation, resolveGridDeleteIntent, } from "../utils/circuit-grid-safety"; +import { + allColumns, + circuitOnlyColumns, + compareSortValues, + defaultVisibleColumnKeys, + deviceFieldKeys, + formatValue, + getBlockSortValue, + getCellKind, + getCircuitValue, + getDeviceValue, + normalizeFilterValue, + parseNumeric, +} from "../utils/circuit-grid-model"; +import type { + CellKey, + CellKind, + ColumnDef, + RowType, +} from "../utils/circuit-grid-model"; import { createCircuit, createCircuitDeviceRow, @@ -38,42 +58,8 @@ import type { ProjectDeviceDto, } from "../types"; -type CellKey = - | "equipmentIdentifier" - | "displayName" - | "quantity" - | "powerPerUnit" - | "simultaneityFactor" - | "rowTotalPower" - | "circuitTotalPower" - | "protectionSummary" - | "cableSummary" - | "roomSummary" - | "remark" - | "technicalName" - | "connectionKind" - | "phaseType" - | "costGroup" - | "category" - | "level" - | "roomNumberSnapshot" - | "roomNameSnapshot" - | "cosPhi" - | "protectionType" - | "protectionRatedCurrent" - | "protectionCharacteristic" - | "cableType" - | "cableCrossSection" - | "cableLength" - | "rcdAssignment" - | "terminalDesignation" - | "status" - | "isReserve"; - type SaveDirection = "stay" | "next" | "prev"; type StartEditMode = "selectExisting" | "replaceWithTypedChar"; -type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder"; -type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; type SortDirection = "asc" | "desc"; interface SelectedCell { @@ -154,131 +140,6 @@ type CircuitReorderDropIntent = const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1"; -interface ColumnDef { - key: CellKey; - label: string; - numeric?: boolean; - defaultVisible?: boolean; - locked?: boolean; -} - -// Centralized column metadata keeps render, filtering/sorting, keyboard traversal, -// and persisted layout in sync. BMK/equipmentIdentifier stays locked as first column -// because circuit identity and circuit drag handles depend on always-visible BMK context. -const allColumns: ColumnDef[] = [ - { key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true }, - { key: "displayName", label: "Display name", defaultVisible: true }, - { key: "quantity", label: "Quantity", numeric: true, defaultVisible: true }, - { key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true }, - { key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true }, - { key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true }, - { key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true }, - { key: "protectionSummary", label: "Protection summary", defaultVisible: true }, - { key: "cableSummary", label: "Cable summary", defaultVisible: true }, - { key: "roomSummary", label: "Room", defaultVisible: true }, - { key: "remark", label: "Remark", defaultVisible: true }, - { key: "technicalName", label: "Technical name" }, - { key: "connectionKind", label: "Connection kind" }, - { key: "phaseType", label: "Phase type" }, - { key: "costGroup", label: "Cost group" }, - { key: "category", label: "Category" }, - { key: "level", label: "Level" }, - { key: "roomNumberSnapshot", label: "Room number" }, - { key: "roomNameSnapshot", label: "Room name" }, - { key: "cosPhi", label: "cosPhi", numeric: true }, - { key: "protectionType", label: "Protection type" }, - { key: "protectionRatedCurrent", label: "Protection rated current", numeric: true }, - { key: "protectionCharacteristic", label: "Protection characteristic" }, - { key: "cableType", label: "Cable type" }, - { key: "cableCrossSection", label: "Cable cross-section" }, - { key: "cableLength", label: "Cable length", numeric: true }, - { key: "rcdAssignment", label: "RCD assignment" }, - { key: "terminalDesignation", label: "Terminal designation" }, - { key: "status", label: "Status" }, - { key: "isReserve", label: "Reserve" }, -]; - -const defaultVisibleColumnKeys = allColumns.filter((column) => column.defaultVisible).map((column) => column.key); - -const deviceOnlyColumns = new Set([ - "quantity", - "powerPerUnit", - "simultaneityFactor", - "rowTotalPower", - "roomSummary", - "technicalName", - "connectionKind", - "phaseType", - "costGroup", - "category", - "level", - "roomNumberSnapshot", - "roomNameSnapshot", - "cosPhi", -]); -const circuitOnlyColumns = new Set([ - "equipmentIdentifier", - "protectionSummary", - "cableSummary", - "circuitTotalPower", - "protectionType", - "protectionRatedCurrent", - "protectionCharacteristic", - "cableType", - "cableCrossSection", - "cableLength", - "rcdAssignment", - "terminalDesignation", - "status", - "isReserve", -]); - -const deviceFieldKeys = new Set([ - "displayName", - "technicalName", - "connectionKind", - "phaseType", - "costGroup", - "category", - "level", - "roomSummary", - "roomNumberSnapshot", - "roomNameSnapshot", - "quantity", - "powerPerUnit", - "simultaneityFactor", - "cosPhi", - "remark", -]); - -const circuitFieldKeys = new Set([ - "equipmentIdentifier", - "displayName", - "protectionType", - "protectionRatedCurrent", - "protectionCharacteristic", - "protectionSummary", - "cableType", - "cableCrossSection", - "cableLength", - "cableSummary", - "rcdAssignment", - "terminalDesignation", - "status", - "isReserve", - "remark", -]); - -function formatValue(value: string | number | boolean | undefined) { - if (value === undefined || value === null || value === "") { - return "-"; - } - if (typeof value === "boolean") { - return value ? "Yes" : "No"; - } - return String(value); -} - function normalizeUiError(err: unknown): string { const message = err instanceof Error ? err.message : "Operation failed."; try { @@ -301,180 +162,6 @@ function normalizeUiError(err: unknown): string { return message; } -function parseNumeric(cellKey: CellKey, draft: string): number | undefined { - const trimmed = draft.trim(); - if (trimmed === "") { - return undefined; - } - const parsed = Number(trimmed); - if (Number.isNaN(parsed)) { - throw new Error(`Invalid number in ${cellKey}`); - } - return parsed; -} - -function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined { - switch (key) { - case "technicalName": - return device.name; - case "displayName": - return device.displayName || device.name; - case "phaseType": - return device.phaseType; - case "connectionKind": - return device.connectionKind; - case "costGroup": - return device.costGroup; - case "category": - return device.category; - case "level": - return device.level; - case "roomSummary": - return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined; - case "roomNumberSnapshot": - return device.roomNumberSnapshot; - case "roomNameSnapshot": - return device.roomNameSnapshot; - case "quantity": - return device.quantity; - case "powerPerUnit": - return device.powerPerUnit; - case "simultaneityFactor": - return device.simultaneityFactor; - case "cosPhi": - return device.cosPhi; - case "rowTotalPower": - return device.rowTotalPower; - case "remark": - return device.remark; - default: - return undefined; - } -} - -function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | number | boolean | undefined { - switch (key) { - case "equipmentIdentifier": - return circuit.equipmentIdentifier; - case "displayName": - return circuit.displayName; - case "circuitTotalPower": - return circuit.circuitTotalPower; - case "protectionType": - return circuit.protectionType; - case "protectionRatedCurrent": - return circuit.protectionRatedCurrent; - case "protectionCharacteristic": - return circuit.protectionCharacteristic; - case "protectionSummary": { - const current = - circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null - ? `${circuit.protectionRatedCurrent}A` - : ""; - return [circuit.protectionType, current, circuit.protectionCharacteristic] - .filter(Boolean) - .join(" ") - .trim() || undefined; - } - case "cableSummary": { - const length = - circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : ""; - return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined; - } - case "cableType": - return circuit.cableType; - case "cableCrossSection": - return circuit.cableCrossSection; - case "cableLength": - return circuit.cableLength; - case "rcdAssignment": - return circuit.rcdAssignment; - case "terminalDesignation": - return circuit.terminalDesignation; - case "status": - return circuit.status; - case "isReserve": - return circuit.isReserve; - case "remark": - return circuit.remark; - default: - return undefined; - } -} - -function normalizeFilterValue(value: string | number | boolean | undefined) { - return formatValue(value); -} - -function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey) { - const firstRow = circuit.deviceRows[0]; - if (circuitOnlyColumns.has(key)) { - return getCircuitValue(circuit, key); - } - if (deviceOnlyColumns.has(key)) { - return firstRow ? getDeviceValue(firstRow, key) : undefined; - } - if (key === "displayName" || key === "remark") { - if (circuit.displayName || circuit.remark) { - return getCircuitValue(circuit, key); - } - return firstRow ? getDeviceValue(firstRow, key) : undefined; - } - return getCircuitValue(circuit, key); -} - -function compareSortValues(a: string | number | boolean | undefined, b: string | number | boolean | undefined) { - const av = a === undefined || a === null ? "" : a; - const bv = b === undefined || b === null ? "" : b; - if (typeof av === "number" && typeof bv === "number") { - return av - bv; - } - return String(av).localeCompare(String(bv), undefined, { sensitivity: "base", numeric: true }); -} - -function getCellKind(rowType: RowType, key: CellKey): CellKind { - if (key === "rowTotalPower" || key === "circuitTotalPower") { - return "computed"; - } - if (rowType === "section") { - return "readonly"; - } - if (rowType === "placeholder") { - if (deviceFieldKeys.has(key)) { - return "deviceField"; - } - if (circuitFieldKeys.has(key)) { - return "circuitField"; - } - return "readonly"; - } - if (rowType === "deviceRow") { - return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; - } - if (rowType === "circuitSummary") { - return circuitFieldKeys.has(key) ? "circuitField" : "readonly"; - } - if (rowType === "reserveCircuit") { - if (deviceFieldKeys.has(key)) { - return "deviceField"; - } - if (circuitFieldKeys.has(key)) { - return "circuitField"; - } - return "readonly"; - } - if (rowType === "circuitCompact") { - if (deviceFieldKeys.has(key)) { - return "deviceField"; - } - if (circuitFieldKeys.has(key)) { - return "circuitField"; - } - return "readonly"; - } - return "readonly"; -} - function isPrintableKey(event: KeyboardEvent) { return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey; } diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts new file mode 100644 index 0000000..f486cd2 --- /dev/null +++ b/src/frontend/utils/circuit-grid-model.ts @@ -0,0 +1,283 @@ +import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js"; + +export type CellKey = + | "equipmentIdentifier" + | "displayName" + | "quantity" + | "powerPerUnit" + | "simultaneityFactor" + | "rowTotalPower" + | "circuitTotalPower" + | "protectionSummary" + | "cableSummary" + | "roomSummary" + | "remark" + | "technicalName" + | "connectionKind" + | "phaseType" + | "costGroup" + | "category" + | "level" + | "roomNumberSnapshot" + | "roomNameSnapshot" + | "cosPhi" + | "protectionType" + | "protectionRatedCurrent" + | "protectionCharacteristic" + | "cableType" + | "cableCrossSection" + | "cableLength" + | "rcdAssignment" + | "terminalDesignation" + | "status" + | "isReserve"; + +export type RowType = + | "section" + | "circuitCompact" + | "circuitSummary" + | "deviceRow" + | "reserveCircuit" + | "placeholder"; +export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly"; +export type GridValue = string | number | boolean | undefined; + +export interface ColumnDef { + key: CellKey; + label: string; + numeric?: boolean; + defaultVisible?: boolean; + locked?: boolean; +} + +// BMK stays locked as the first column because circuit identity and circuit drag +// handles depend on an always-visible equipment identifier. +export const allColumns: ColumnDef[] = [ + { key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true }, + { key: "displayName", label: "Display name", defaultVisible: true }, + { key: "quantity", label: "Quantity", numeric: true, defaultVisible: true }, + { key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true }, + { key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true }, + { key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true }, + { key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true }, + { key: "protectionSummary", label: "Protection summary", defaultVisible: true }, + { key: "cableSummary", label: "Cable summary", defaultVisible: true }, + { key: "roomSummary", label: "Room", defaultVisible: true }, + { key: "remark", label: "Remark", defaultVisible: true }, + { key: "technicalName", label: "Technical name" }, + { key: "connectionKind", label: "Connection kind" }, + { key: "phaseType", label: "Phase type" }, + { key: "costGroup", label: "Cost group" }, + { key: "category", label: "Category" }, + { key: "level", label: "Level" }, + { key: "roomNumberSnapshot", label: "Room number" }, + { key: "roomNameSnapshot", label: "Room name" }, + { key: "cosPhi", label: "cosPhi", numeric: true }, + { key: "protectionType", label: "Protection type" }, + { key: "protectionRatedCurrent", label: "Protection rated current", numeric: true }, + { key: "protectionCharacteristic", label: "Protection characteristic" }, + { key: "cableType", label: "Cable type" }, + { key: "cableCrossSection", label: "Cable cross-section" }, + { key: "cableLength", label: "Cable length", numeric: true }, + { key: "rcdAssignment", label: "RCD assignment" }, + { key: "terminalDesignation", label: "Terminal designation" }, + { key: "status", label: "Status" }, + { key: "isReserve", label: "Reserve" }, +]; + +export const defaultVisibleColumnKeys = allColumns + .filter((column) => column.defaultVisible) + .map((column) => column.key); + +const deviceOnlyColumns = new Set([ + "quantity", + "powerPerUnit", + "simultaneityFactor", + "rowTotalPower", + "roomSummary", + "technicalName", + "connectionKind", + "phaseType", + "costGroup", + "category", + "level", + "roomNumberSnapshot", + "roomNameSnapshot", + "cosPhi", +]); + +export const circuitOnlyColumns = new Set([ + "equipmentIdentifier", + "protectionSummary", + "cableSummary", + "circuitTotalPower", + "protectionType", + "protectionRatedCurrent", + "protectionCharacteristic", + "cableType", + "cableCrossSection", + "cableLength", + "rcdAssignment", + "terminalDesignation", + "status", + "isReserve", +]); + +export const deviceFieldKeys = new Set([ + "displayName", + "technicalName", + "connectionKind", + "phaseType", + "costGroup", + "category", + "level", + "roomSummary", + "roomNumberSnapshot", + "roomNameSnapshot", + "quantity", + "powerPerUnit", + "simultaneityFactor", + "cosPhi", + "remark", +]); + +const circuitFieldKeys = new Set([ + "equipmentIdentifier", + "displayName", + "protectionType", + "protectionRatedCurrent", + "protectionCharacteristic", + "protectionSummary", + "cableType", + "cableCrossSection", + "cableLength", + "cableSummary", + "rcdAssignment", + "terminalDesignation", + "status", + "isReserve", + "remark", +]); + +export function formatValue(value: GridValue): string { + if (value === undefined || value === null || value === "") { + return "-"; + } + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + return String(value); +} + +export function parseNumeric(cellKey: CellKey, draft: string): number | undefined { + const trimmed = draft.trim(); + if (trimmed === "") { + return undefined; + } + const parsed = Number(trimmed); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid number in ${cellKey}`); + } + return parsed; +} + +export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue { + switch (key) { + case "technicalName": return device.name; + case "displayName": return device.displayName || device.name; + case "phaseType": return device.phaseType; + case "connectionKind": return device.connectionKind; + case "costGroup": return device.costGroup; + case "category": return device.category; + case "level": return device.level; + case "roomSummary": return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined; + case "roomNumberSnapshot": return device.roomNumberSnapshot; + case "roomNameSnapshot": return device.roomNameSnapshot; + case "quantity": return device.quantity; + case "powerPerUnit": return device.powerPerUnit; + case "simultaneityFactor": return device.simultaneityFactor; + case "cosPhi": return device.cosPhi; + case "rowTotalPower": return device.rowTotalPower; + case "remark": return device.remark; + default: return undefined; + } +} + +export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue { + switch (key) { + case "equipmentIdentifier": return circuit.equipmentIdentifier; + case "displayName": return circuit.displayName; + case "circuitTotalPower": return circuit.circuitTotalPower; + case "protectionType": return circuit.protectionType; + case "protectionRatedCurrent": return circuit.protectionRatedCurrent; + case "protectionCharacteristic": return circuit.protectionCharacteristic; + case "protectionSummary": { + const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null + ? `${circuit.protectionRatedCurrent}A` + : ""; + return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined; + } + case "cableSummary": { + const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : ""; + return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined; + } + case "cableType": return circuit.cableType; + case "cableCrossSection": return circuit.cableCrossSection; + case "cableLength": return circuit.cableLength; + case "rcdAssignment": return circuit.rcdAssignment; + case "terminalDesignation": return circuit.terminalDesignation; + case "status": return circuit.status; + case "isReserve": return circuit.isReserve; + case "remark": return circuit.remark; + default: return undefined; + } +} + +export function normalizeFilterValue(value: GridValue): string { + return formatValue(value); +} + +export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue { + const firstRow = circuit.deviceRows[0]; + if (circuitOnlyColumns.has(key)) { + return getCircuitValue(circuit, key); + } + if (deviceOnlyColumns.has(key)) { + return firstRow ? getDeviceValue(firstRow, key) : undefined; + } + if (key === "displayName") { + if (circuit.displayName) return circuit.displayName; + return firstRow ? getDeviceValue(firstRow, key) : undefined; + } + if (key === "remark") { + if (circuit.remark) return circuit.remark; + return firstRow ? getDeviceValue(firstRow, key) : undefined; + } + return getCircuitValue(circuit, key); +} + +export function compareSortValues(left: GridValue, right: GridValue): number { + const leftValue = left === undefined || left === null ? "" : left; + const rightValue = right === undefined || right === null ? "" : right; + if (typeof leftValue === "number" && typeof rightValue === "number") { + return leftValue - rightValue; + } + return String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: "base", numeric: true }); +} + +export function getCellKind(rowType: RowType, key: CellKey): CellKind { + if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed"; + if (rowType === "section") return "readonly"; + if (rowType === "placeholder") { + if (deviceFieldKeys.has(key)) return "deviceField"; + if (circuitFieldKeys.has(key)) return "circuitField"; + return "readonly"; + } + if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly"; + if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly"; + if (rowType === "reserveCircuit" || rowType === "circuitCompact") { + if (deviceFieldKeys.has(key)) return "deviceField"; + if (circuitFieldKeys.has(key)) return "circuitField"; + } + return "readonly"; +} diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts new file mode 100644 index 0000000..790d7b5 --- /dev/null +++ b/tests/circuit-grid-model.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + allColumns, + getBlockSortValue, + getCellKind, + getCircuitValue, + getDeviceValue, + parseNumeric, +} from "../src/frontend/utils/circuit-grid-model.js"; +import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js"; + +const device: CircuitTreeDeviceRowDto = { + id: "row-1", + sortOrder: 10, + name: "Technical light", + displayName: "Office light", + phaseType: "single_phase", + roomNumberSnapshot: "1.01", + roomNameSnapshot: "Office", + quantity: 2, + powerPerUnit: 0.05, + simultaneityFactor: 0.8, + rowTotalPower: 0.08, + remark: "Device remark", +}; + +const circuit: CircuitTreeCircuitDto = { + id: "circuit-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1F1", + displayName: "Lighting circuit", + sortOrder: 10, + protectionType: "MCB", + protectionRatedCurrent: 16, + protectionCharacteristic: "B", + cableType: "NYM-J", + cableCrossSection: "1.5 mm²", + cableLength: 20, + isReserve: false, + remark: "Circuit remark", + circuitTotalPower: 0.08, + deviceRows: [device], +}; + +describe("circuit grid model", () => { + it("keeps the equipment identifier locked as the first column", () => { + assert.equal(allColumns[0].key, "equipmentIdentifier"); + assert.equal(allColumns[0].locked, true); + }); + + it("maps shared fields to the correct level for every row shape", () => { + assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField"); + assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField"); + assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField"); + assert.equal(getCellKind("deviceRow", "displayName"), "deviceField"); + assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField"); + assert.equal(getCellKind("placeholder", "displayName"), "deviceField"); + assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly"); + assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed"); + }); + + it("projects circuit and device values without mixing ownership", () => { + assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office"); + assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08); + assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B"); + assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m"); + }); + + it("uses circuit display values for block sorting and falls back to the first device", () => { + assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit"); + assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light"); + assert.equal(getBlockSortValue(circuit, "quantity"), 2); + }); + + it("parses numeric drafts and rejects invalid values", () => { + assert.equal(parseNumeric("quantity", " 2.5 "), 2.5); + assert.equal(parseNumeric("quantity", ""), undefined); + assert.throws(() => parseNumeric("quantity", "two"), /Invalid number/); + }); +});