diff --git a/docs/current-architecture.md b/docs/current-architecture.md index a53bbe6..0534219 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -350,6 +350,11 @@ enthalten ihre optionale getrennte Schutzgerätekonfiguration. Die bisherigen flachen Stromkreis-Schutzfelder bleiben während der Übergangsphase additiv erhalten. Separate aktive Read-Repositories lesen Komponenten- und beide Schutzgerätetabellen; Schreibzugriffe bleiben ausschließlich in Commands. +`src/frontend/utils/circuit-structure-projection.ts` projiziert diesen Tree +rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß. +Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten, +vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben +sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index f4849c6..4614858 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -32,7 +32,8 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase D3a: complete validated group-subtree snapshot and warning summary. - [x] Phase D3b: persistent populated-group delete/restore command. - [x] Phase E1: tree read model for components, groups and protection devices. -- [ ] Phase E2: header/group/footer editor projection. +- [x] Phase E2a: pure header/group/footer structure projection. +- [ ] Phase E2b: render the structure projection in the editor grid. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until diff --git a/docs/spec/09-distribution-board-components-and-protection-groups.md b/docs/spec/09-distribution-board-components-and-protection-groups.md index 175241f..5bdbd87 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -739,6 +739,9 @@ Acceptance: ### E. Editor Projection and Editing +Status: In progress. The complete tree read model E1 and pure structural +projection E2a are complete. Grid rendering and editing remain pending. + - render fixed header components - render group components and circuit blocks - render sortable auxiliary footer components @@ -746,6 +749,17 @@ Acceptance: - add type-dependent protection editing - keep spreadsheet selection, keyboard and drag behavior coherent +Implemented in E1/E2a: + +- the tree response exposes fixed header, group and auxiliary footer + components plus both one-to-one protection layers +- a pure projection produces the stable order header, complete groups and + footer without mixing zones +- each group keeps its heading, optional protection components, complete + circuit blocks and free placeholder together +- empty groups remain visible and circuit device rows remain owned by their + circuit block + Acceptance: - the visible order is header, groups, footer diff --git a/src/frontend/utils/circuit-structure-projection.ts b/src/frontend/utils/circuit-structure-projection.ts new file mode 100644 index 0000000..9c2f15d --- /dev/null +++ b/src/frontend/utils/circuit-structure-projection.ts @@ -0,0 +1,102 @@ +import type { + CircuitTreeCircuitDto, + CircuitTreeComponentDto, + CircuitTreeResponseDto, + CircuitTreeSectionDto, +} from "../types.js"; + +export type CircuitStructureProjectionRow = + | { + rowKey: string; + rowType: "headerComponent" | "footerComponent"; + zone: "header" | "footer"; + component: CircuitTreeComponentDto; + } + | { + rowKey: string; + rowType: "groupHeader" | "groupPlaceholder"; + zone: "group"; + section: CircuitTreeSectionDto; + } + | { + rowKey: string; + rowType: "groupComponent"; + zone: "group"; + section: CircuitTreeSectionDto; + component: CircuitTreeComponentDto; + } + | { + rowKey: string; + rowType: "circuitBlock"; + zone: "group"; + section: CircuitTreeSectionDto; + circuit: CircuitTreeCircuitDto; + }; + +export function buildCircuitStructureProjection( + tree: Pick< + CircuitTreeResponseDto, + "headerComponents" | "sections" | "footerComponents" + > +): CircuitStructureProjectionRow[] { + const rows: CircuitStructureProjectionRow[] = []; + for (const component of ordered(tree.headerComponents)) { + rows.push({ + rowKey: `header-component:${component.id}`, + rowType: "headerComponent", + zone: "header", + component, + }); + } + for (const section of ordered(tree.sections)) { + rows.push({ + rowKey: `group:${section.id}`, + rowType: "groupHeader", + zone: "group", + section, + }); + for (const component of ordered(section.components)) { + rows.push({ + rowKey: `group-component:${component.id}`, + rowType: "groupComponent", + zone: "group", + section, + component, + }); + } + for (const circuit of section.circuits) { + rows.push({ + rowKey: `circuit-block:${circuit.id}`, + rowType: "circuitBlock", + zone: "group", + section, + circuit, + }); + } + rows.push({ + rowKey: `group-placeholder:${section.id}`, + rowType: "groupPlaceholder", + zone: "group", + section, + }); + } + for (const component of ordered(tree.footerComponents)) { + rows.push({ + rowKey: `footer-component:${component.id}`, + rowType: "footerComponent", + zone: "footer", + component, + }); + } + return rows; +} + +function ordered( + values: readonly T[] +) { + return [...values].sort( + (left, right) => + left.sortOrder - right.sortOrder || + left.id.localeCompare(right.id) + ); +} diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index e656084..ad241e0 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -10,6 +10,7 @@ import type { CircuitTreeDeviceRowDto, CircuitTreeSectionDto, } from "../src/frontend/types.js"; +import "./circuit-structure-projection.test.js"; function makeDevice( id: string, diff --git a/tests/circuit-structure-projection.test.ts b/tests/circuit-structure-projection.test.ts new file mode 100644 index 0000000..a36e96b --- /dev/null +++ b/tests/circuit-structure-projection.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildCircuitStructureProjection } from "../src/frontend/utils/circuit-structure-projection.js"; +import type { + CircuitTreeCircuitDto, + CircuitTreeComponentDto, + CircuitTreeSectionDto, +} from "../src/frontend/types.js"; + +function component( + id: string, + placement: CircuitTreeComponentDto["placement"], + sortOrder: number, + sectionId?: string +): CircuitTreeComponentDto { + return { + id, + circuitListId: "list-1", + sectionId, + equipmentIdentifier: `-${id}`, + name: id, + role: + placement === "header" + ? "main_switch" + : placement === "footer" + ? "auxiliary" + : "group_residual_current_protection", + placement, + sortOrder, + }; +} + +function circuit( + id: string, + sectionId: string +): CircuitTreeCircuitDto { + return { + id, + circuitListId: "list-1", + sectionId, + equipmentIdentifier: `-${id}`, + sortOrder: 10, + isReserve: true, + circuitTotalPower: 0, + deviceRows: [], + }; +} + +function section( + id: string, + sortOrder: number +): CircuitTreeSectionDto { + return { + id, + key: id, + displayName: id, + prefix: "-1F1.", + sortOrder, + category: "lighting", + groupNumber: sortOrder / 10, + sectionTotalPower: 0, + components: [ + component(`${id}-rcd-late`, "group", 20, id), + component(`${id}-rcd-first`, "group", 10, id), + ], + circuits: [circuit(`${id}-circuit`, id)], + }; +} + +describe("circuit structure projection", () => { + it("projects fixed header, complete groups and auxiliary footer in stable order", () => { + const rows = buildCircuitStructureProjection({ + headerComponents: [ + component("surge", "header", 20), + component("main", "header", 10), + ], + sections: [section("group-2", 20), section("group-1", 10)], + footerComponents: [ + component("actor-2", "footer", 20), + component("actor-1", "footer", 10), + ], + }); + assert.deepEqual( + rows.map((row) => row.rowKey), + [ + "header-component:main", + "header-component:surge", + "group:group-1", + "group-component:group-1-rcd-first", + "group-component:group-1-rcd-late", + "circuit-block:group-1-circuit", + "group-placeholder:group-1", + "group:group-2", + "group-component:group-2-rcd-first", + "group-component:group-2-rcd-late", + "circuit-block:group-2-circuit", + "group-placeholder:group-2", + "footer-component:actor-1", + "footer-component:actor-2", + ] + ); + }); + + it("keeps structural rows around an empty group", () => { + const empty = section("group-empty", 10); + empty.components = []; + empty.circuits = []; + assert.deepEqual( + buildCircuitStructureProjection({ + headerComponents: [], + sections: [empty], + footerComponents: [], + }).map((row) => row.rowType), + ["groupHeader", "groupPlaceholder"] + ); + }); + + it("keeps circuit device rows inside their circuit block", () => { + const grouped = section("group-1", 10); + grouped.circuits[0].deviceRows = [ + { + id: "device-1", + sortOrder: 10, + name: "Leuchte", + displayName: "Leuchte", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + rowTotalPower: 0.1, + }, + ]; + const block = buildCircuitStructureProjection({ + headerComponents: [], + sections: [grouped], + footerComponents: [], + }).find((row) => row.rowType === "circuitBlock"); + assert.equal( + block?.rowType === "circuitBlock" + ? block.circuit.deviceRows[0].id + : null, + "device-1" + ); + }); +});