From 76d8d5939146feedd514f33d40d05377e7f381a9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 20:26:45 +0200 Subject: [PATCH] Persist circuit cell edits --- AGENTS.md | 21 +- docs/circuit-list-editor-api.md | 21 +- docs/circuit-list-editor-interactions.md | 16 +- docs/circuit-list-editor-known-limitations.md | 9 +- docs/current-architecture.md | 17 +- ...history-and-external-model-architecture.md | 13 +- docs/spec/07-implementation-phases-todo.md | 12 +- .../circuit-device-row.repository.ts | 11 -- src/db/repositories/circuit.repository.ts | 12 -- src/domain/models/circuit-tree.model.ts | 1 + src/domain/services/circuit-write.service.ts | 35 ---- .../components/circuit-tree-editor.tsx | 181 +++++++++++------- src/frontend/types.ts | 4 +- src/frontend/utils/api.ts | 44 +++-- src/frontend/utils/circuit-grid-model.ts | 74 +++++++ .../circuit-device-row.controller.ts | 20 -- .../controllers/circuit-tree.controller.ts | 8 + src/server/controllers/circuit.controller.ts | 20 -- .../routes/circuit-device-row.routes.ts | 2 - src/server/routes/circuit.routes.ts | 2 - src/shared/validation/circuit.schemas.ts | 11 -- tests/circuit-grid-model.test.ts | 36 ++++ ...circuit-project-command.repository.test.ts | 8 + tests/circuit-write.rules.test.ts | 71 ------- 24 files changed, 348 insertions(+), 301 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d155b1..c432686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,15 +220,18 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -Session-local UI undo/redo exists for structural and destructive operations. -The server already persists immutable revisions and project-wide undo/redo -eligibility for the first commands. Public command, undo and redo endpoints -exist for Circuit and CircuitDeviceRow field updates as well as -Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and -the UI cutover remain future work. CircuitDeviceRow moves between existing -circuits and moves that create one new placeholder target circuit are also -persisted. The latter stores the complete empty target snapshot so undo can -restore the rows and remove only the unchanged generated circuit. Complete +The editor's visible undo/redo stack remains session-local while structural and +destructive operations are being migrated. Existing Circuit and +CircuitDeviceRow cell edits already execute persistent project commands and +their toolbar undo/redo actions use the project-wide history endpoints. The +tree response supplies the optimistic `currentRevision`; the obsolete direct +PATCH routes for these fields are removed. Reloading the page does not yet +reconstruct the visible editor stack. Public command, undo and redo endpoints +also exist for Circuit/CircuitDeviceRow insertion and deletion. +CircuitDeviceRow moves between existing circuits and moves that create one new +placeholder target circuit are persisted. The latter stores the complete empty +target snapshot so undo can restore the rows and remove only the unchanged +generated circuit. Complete in-section Circuit reorders are persisted separately and change sort positions without changing equipment identifiers. Explicit complete-section renumbering is persisted through a separate collision-safe command and is never triggered diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 7339d9a..3c88b16 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -5,9 +5,9 @@ The circuit-first editor uses tree, circuit, row and project-device endpoints. All paths below are mounted below `/api`. There is no Consumer application API. -Project responses from `GET /projects` and `GET /projects/:projectId` include -`currentRevision`. Versioned project commands use this value for optimistic -concurrency checks. +Project responses from `GET /projects`, `GET /projects/:projectId` and the +circuit-tree endpoint include `currentRevision`. Versioned project commands use +this value for optimistic concurrency checks. ### Project Commands and History @@ -35,8 +35,9 @@ with stable ids; delete commands include the expected parent identity. Circuit snapshots contain zero, one or multiple complete device rows. Move commands contain each row's expected and target circuit plus its exact expected and target sort order. This makes deletion and moves undoable without generating -replacement identities or renumbering circuits. The editor does not consume -these endpoints yet. +replacement identities or renumbering circuits. Existing Circuit and +CircuitDeviceRow cell edits in the editor consume these endpoints; the +remaining structural editor writes are still being migrated. `circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move-with-new-circuit` atomically creates exactly one @@ -137,6 +138,7 @@ Response sketch: ```json { "circuitListId": "cl_1", + "currentRevision": 12, "sections": [ { "id": "sec_1", @@ -167,8 +169,6 @@ Response sketch: - create circuit in list/section - `POST /projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows` - atomically create one circuit and one or more initial device rows -- `PATCH /circuits/:circuitId` - - update circuit-level fields (BMK, protection, cable, reserve flag, etc.) - `DELETE /circuits/:circuitId` - delete circuit (and related rows via DB relations) - `GET /circuit-sections/:sectionId/next-identifier` @@ -189,11 +189,14 @@ Request sketch (`POST .../circuits`): - `POST /circuits/:circuitId/device-rows` - create row inside a circuit -- `PATCH /circuit-device-rows/:rowId` - - update row values - `DELETE /circuit-device-rows/:rowId` - delete row +Circuit and device-row field updates are available only as versioned +`circuit.update` and `circuit-device-row.update` commands through +`POST /projects/:projectId/commands`. There are no direct field-update PATCH +routes. + ### Move Device Row - `PATCH /circuit-device-rows/:rowId/move` diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index a58391f..3e04d8e 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -11,7 +11,10 @@ Inline cells are static text by default. A cell enters edit mode by: `Enter` confirms changes. `Escape` cancels current edit draft. `Tab` and `Shift+Tab` confirm and move to next/previous editable cell. -Committed cell edits are persisted as partial database updates. Fields that were not part of the edit are not written back from an older read and therefore cannot overwrite a concurrent change to another field. +Committed cells on existing circuits and device rows are persisted as typed +project commands with an optimistic project revision. Only the edited fields +are included, so an older tree response cannot write unrelated fields back. +The server records the inverse in the same transaction. ## `selectedCell` vs `editingCell` @@ -119,7 +122,11 @@ Cross-section device moves show confirmation-required feedback before drop. The ## Undo/Redo -Undo/redo wraps editor operations as command objects with async `redo`/`undo` and reloads tree after each command. +Undo/redo wraps editor operations as command objects and reloads the tree after +each command. Existing Circuit and device-row cell edits execute persistent +project commands; their toolbar undo/redo calls the project-wide server +history. Other covered operations still use their existing direct API writes +and session-local inverse callbacks during the cutover. Covered operations include: @@ -132,5 +139,6 @@ Covered operations include: Current limitations: -- session-local only -- no persisted history across browser reload +- the visible editor stack is still session-local and is empty after reload +- structural and destructive editor actions are not all connected to the + persistent command boundary yet diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 8e5369c..5da1990 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -1,9 +1,12 @@ # Circuit List Editor Known Limitations -- The circuit-list editor still exposes only its session-local undo/redo UI. +- Existing Circuit and CircuitDeviceRow cell edits use persistent project + commands and project-wide toolbar undo/redo. The editor's visible stack is + still session-local, is empty after reload and still contains direct + structural/destructive operations during the cutover. - Server-side project revisions and persistent undo/redo eligibility exist for - the command types documented in `circuit-list-editor-api.md`; command coverage - and the editor UI cutover are not complete. + the command types documented in `circuit-list-editor-api.md`; complete editor + command coverage and history reconstruction are not complete. - Named logical project snapshots and restore are not implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 9139299..b07bcac 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -41,7 +41,7 @@ liegt über einen Host-Mount außerhalb des Containers. - `src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx` – unterstützte Editorroute - `src/frontend/components/circuit-tree-editor.tsx` – Editorzustand, - Befehlsausführung, Drag-and-drop und sitzungslokales Undo/Redo + Befehlsausführung, Drag-and-drop und der schrittweise Historien-Cutover - `src/frontend/components/circuit-grid-*.ts` – reine Grid-Projektion, Zellbesitz, Einfügen und Sicherheitsregeln - `src/frontend/utils/api.ts` – typisierte Frontend-API-Aufrufe @@ -62,7 +62,8 @@ liegt über einen Host-Mount außerhalb des Containers. 6. Das Frontend lädt den Circuit-Tree neu und stellt Auswahl beziehungsweise Viewport soweit möglich wieder her. -Die React-Historie ist derzeit sitzungslokal. Das Datenmodell besitzt bereits +Der sichtbare React-Historiestapel ist während des schrittweisen Cutovers noch +sitzungslokal. Das Datenmodell besitzt einen projektbezogenen Revisionszähler sowie getrennte Revision-/Change-Set- Tabellen. Ein getestetes Repository kann diese Historienmetadaten optimistisch und atomar fortschreiben. Vorwärts- und Rückwärtskommandos besitzen einen @@ -85,8 +86,13 @@ inversen Kommando gesichert, sodass Undo dieselbe UUID und alle Fachwerte wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine -Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine -sichtbare Historie bleibt daher sitzungslokal. +Betriebsmittelkennzeichen. Bestehende Circuit- und Gerätezeilen-Zelländerungen +verwendet das Grid bereits über die öffentliche Command-Grenze. Der Tree liefert +dazu `currentRevision`; Undo/Redo für diese Zellaktionen läuft über die +projektweite Serverhistorie. Die direkten PATCH-Endpunkte für diese Felder sind +entfernt. Andere Editoraktionen verbleiben vorerst im sichtbaren +sitzungslokalen Stack; nach einem Reload wird dieser noch nicht aus der +Serverhistorie rekonstruiert. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; @@ -159,7 +165,8 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. ## Noch nicht unterstützt -- persistentes Undo/Redo und Projektversionen +- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und + benannte Projektstände - Mehrbenutzerbetrieb und Konfliktauflösung - Revit-/CSV-/IFCGUID-Round-trip - vollständige elektrische Dimensionierung diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index bd4e825..0e0c4e7 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -173,6 +173,11 @@ Completed foundation: to SQLite - public command, undo and redo endpoints require an expected revision; undo and redo reconstruct the eligible persisted inverse or forward command +- the circuit tree returns the project's current revision; existing Circuit + and CircuitDeviceRow cell edits use the public command endpoint, while their + toolbar undo/redo actions use project-wide persistent history +- obsolete direct Circuit and CircuitDeviceRow field-update PATCH routes are + removed so these editor writes cannot bypass revision history - `circuit-device-row.insert` and `circuit-device-row.delete` preserve stable row ids, complete deleted-row snapshots and circuit reserve state through atomic insert/delete, revision and stack transitions @@ -210,10 +215,10 @@ Remaining constraints before completing persistent history: is complete - route the remaining structural and destructive domain writes through the shared command, revision and stack transaction boundary -- replace the session-local frontend command stack only after server-side - command coverage is complete for structural and destructive circuit-editor - operations; the ProjectDevice synchronization UI already uses its persistent - command and project-wide undo endpoint +- finish routing structural and destructive circuit-editor operations through + persistent commands, then reconstruct the visible frontend history state + after reload; Circuit/CircuitDeviceRow cell edits and ProjectDevice + synchronization already use persistent commands and project-wide undo - do not model IFCGUID as an overloaded circuit equipment identifier - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index bb11e14..ab6774d 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -390,6 +390,9 @@ Implemented foundation: - a central application dispatcher reconstructs persisted commands and exposes optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow field updates +- the circuit tree exposes `currentRevision`; existing Circuit and + CircuitDeviceRow cell edits use those optimistic commands and project-wide + undo/redo instead of direct field-update PATCH routes - typed CircuitDeviceRow insert/delete commands preserve complete row snapshots and stable ids; row mutation, circuit reserve state, revision and history stack transition commit or roll back together @@ -424,10 +427,11 @@ Implemented foundation: - integration coverage verifies sequential revisions, stale-command rejection and rollback after a forced late persistence failure -ProjectDevice CRUD, synchronization and disconnect on the project page are -connected to this history boundary. The circuit-list editor and the remaining -domain writes are not fully connected; its visible undo/redo therefore remains -session-local until the remaining tasks are implemented. +ProjectDevice CRUD, synchronization and disconnect on the project page and +existing Circuit/CircuitDeviceRow cell edits in the circuit-list editor are +connected to this history boundary. Remaining structural editor writes are not +fully connected; the editor's visible undo/redo stack therefore remains +session-local and is not reconstructed after reload. Acceptance criteria: diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 4cf21a4..413f296 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -7,15 +7,12 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { toCircuitDeviceRowCreateValues, - toCircuitDeviceRowPatchValues, toCircuitDeviceRowUpdateValues, type CircuitDeviceRowCreateInput, - type CircuitDeviceRowPatchInput, type CircuitDeviceRowUpdateInput, } from "./circuit-device-row.persistence.js"; export type { CircuitDeviceRowCreateInput, - CircuitDeviceRowPatchInput, CircuitDeviceRowUpdateInput, } from "./circuit-device-row.persistence.js"; export { toCircuitDeviceRowCreateValues } from "./circuit-device-row.persistence.js"; @@ -102,14 +99,6 @@ export class CircuitDeviceRowRepository { .where(eq(circuitDeviceRows.id, rowId)); } - async updateFields(rowId: string, input: CircuitDeviceRowPatchInput) { - const values = toCircuitDeviceRowPatchValues(input); - if (Object.keys(values).length === 0) { - return; - } - await db.update(circuitDeviceRows).set(values).where(eq(circuitDeviceRows.id, rowId)); - } - async delete(rowId: string) { await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); } diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 627d0d6..453a07a 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -4,18 +4,14 @@ import { db } from "../client.js"; import { circuits } from "../schema/circuits.js"; import { toCircuitCreateValues, - toCircuitPatchValues, type CircuitCreatePersistenceInput, - type CircuitPatchPersistenceInput, } from "./circuit.persistence.js"; export type { CircuitCreatePersistenceInput, - CircuitPatchPersistenceInput, } from "./circuit.persistence.js"; export { toCircuitCreateValues, - toCircuitPatchValues, } from "./circuit.persistence.js"; export class CircuitRepository { @@ -84,14 +80,6 @@ export class CircuitRepository { .where(eq(circuits.id, circuitId)); } - async updateFields(circuitId: string, input: CircuitPatchPersistenceInput) { - const values = toCircuitPatchValues(input); - if (Object.keys(values).length === 0) { - return; - } - await db.update(circuits).set(values).where(eq(circuits.id, circuitId)); - } - async delete(circuitId: string) { await db.delete(circuits).where(eq(circuits.id, circuitId)); } diff --git a/src/domain/models/circuit-tree.model.ts b/src/domain/models/circuit-tree.model.ts index 6420c0b..311d3b0 100644 --- a/src/domain/models/circuit-tree.model.ts +++ b/src/domain/models/circuit-tree.model.ts @@ -57,6 +57,7 @@ export interface CircuitTreeSectionBlock { export interface CircuitTreeResponse { circuitListId: string; + currentRevision: number; sections: CircuitTreeSectionBlock[]; } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 9b72dde..0cf7f25 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -13,11 +13,8 @@ import type { MoveCircuitDeviceRowsBulkInput, ReorderSectionCircuitsInput, UpdateSectionEquipmentIdentifiersInput, - UpdateCircuitDeviceRowInput, - UpdateCircuitInput, } from "../../shared/validation/circuit.schemas.js"; import { CircuitNumberingService } from "./circuit-numbering.service.js"; -import { deriveOverriddenFieldsForLocalEdit } from "./project-device-overrides.js"; export class CircuitWriteService { private readonly circuitRepository: CircuitRepository; @@ -191,21 +188,6 @@ export class CircuitWriteService { }; } - async updateCircuit(circuitId: string, input: UpdateCircuitInput) { - const current = await this.circuitRepository.findById(circuitId); - if (!current) { - throw new Error("Invalid circuit id."); - } - - const sectionId = input.sectionId ?? current.sectionId; - const equipmentIdentifier = input.equipmentIdentifier ?? current.equipmentIdentifier; - await this.assertSectionInList(sectionId, current.circuitListId); - await this.assertUniqueEquipmentIdentifier(current.circuitListId, equipmentIdentifier, circuitId); - - await this.circuitRepository.updateFields(circuitId, input); - return this.circuitRepository.findById(circuitId); - } - async deleteCircuit(circuitId: string) { const current = await this.circuitRepository.findById(circuitId); if (!current) { @@ -246,23 +228,6 @@ export class CircuitWriteService { return this.deviceRowRepository.findById(rowId); } - async updateDeviceRow(rowId: string, input: UpdateCircuitDeviceRowInput) { - const current = await this.deviceRowRepository.findById(rowId); - if (!current) { - throw new Error("Invalid device row id."); - } - await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId); - const overriddenFields = deriveOverriddenFieldsForLocalEdit( - current, - input - ); - await this.deviceRowRepository.updateFields(rowId, { - ...input, - ...(overriddenFields !== undefined ? { overriddenFields } : {}), - }); - return this.deviceRowRepository.findById(rowId); - } - async deleteDeviceRow(rowId: string) { const current = await this.deviceRowRepository.findById(rowId); if (!current) { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index cb05281..866dd4b 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -17,12 +17,11 @@ import { } from "../utils/circuit-grid-safety"; import { allColumns, + buildCircuitEditPatch, + buildDeviceRowEditPatch, defaultVisibleColumnKeys, deviceFieldKeys, formatValue, - getCircuitValue, - getDeviceValue, - parseNumeric, } from "../utils/circuit-grid-model"; import type { CellKey, @@ -49,14 +48,17 @@ import { moveCircuitDeviceRowById, reorderSectionCircuits, renumberCircuitSection, + redoProjectCommand, updateSectionEquipmentIdentifiers, updateCircuitById, updateCircuitDeviceRowById, + undoProjectCommand, } from "../utils/api"; import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CircuitTreeResponseDto, + ProjectCommandResultDto, ProjectDeviceDto, } from "../types"; @@ -88,6 +90,10 @@ interface HistoryCommand { label: string; redo: () => Promise; undo: () => Promise; + persistent?: { + undoIntent: () => SelectionIntent | null; + redoIntent: () => SelectionIntent | null; + }; } interface CircuitSnapshot { @@ -163,6 +169,16 @@ function isPrintableKey(event: KeyboardEvent) { return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey; } +function createValuesFromEditPatch( + patch: Record +): Record { + return Object.fromEntries( + Object.entries(patch).filter( + ([, value]) => value !== null && value !== "" + ) + ); +} + export function CircuitTreeEditor(props: { projectId: string; circuitListId: string }) { /* Circuit-tree editor invariant overview: @@ -201,7 +217,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); - // Undo/redo history is session-local UI state (not persisted server-side). + // The visible stack is session-local during the cutover. Circuit/device field + // commands already use the persistent project-wide server history. const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); const [historyBusy, setHistoryBusy] = useState(false); @@ -223,6 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const containerRef = useRef(null); const inputRef = useRef(null); const focusTokenRef = useRef(1); + const projectRevisionRef = useRef(null); const orderedColumns = useMemo( () => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[], @@ -241,6 +259,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(null); try { const tree = await getCircuitTree(projectId, circuitListId); + projectRevisionRef.current = tree.currentRevision; setData(tree); } catch (err) { setError(normalizeUiError(err)); @@ -767,6 +786,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } } + function getExpectedProjectRevision() { + if (projectRevisionRef.current === null) { + throw new Error("Der Projektstand ist noch nicht geladen."); + } + return projectRevisionRef.current; + } + + function applyProjectCommandResult(result: ProjectCommandResultDto) { + projectRevisionRef.current = result.history.currentRevision; + setData((current) => + current + ? { + ...current, + currentRevision: result.history.currentRevision, + } + : current + ); + } + // Runs a normal command and records it in session-local history. async function runCommand(command: HistoryCommand) { try { @@ -790,7 +828,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError(null); setHistoryBusy(true); setIsSaving(true); - const intent = mode === "undo" ? await command.undo() : await command.redo(); + let intent: SelectionIntent | null | void; + if (command.persistent) { + const result = + mode === "undo" + ? await undoProjectCommand( + projectId, + getExpectedProjectRevision() + ) + : await redoProjectCommand( + projectId, + getExpectedProjectRevision() + ); + applyProjectCommandResult(result); + intent = + mode === "undo" + ? command.persistent.undoIntent() + : command.persistent.redoIntent(); + } else { + intent = + mode === "undo" + ? await command.undo() + : await command.redo(); + } await reloadWithIntent(intent ?? null); if (mode === "undo") { setUndoStack((current) => current.slice(0, -1)); @@ -1145,46 +1205,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setSelectedCell({ rowKey: targetRowKey, cellKey: best }); } - // Maps generic grid cell edits to circuit PATCH payload fields. + // Maps grid edits to typed persistent project commands. async function patchCircuit(circuitId: string, key: CellKey, draft: string) { - const payload: Record = {}; - if (key === "protectionSummary" || key === "cableSummary") { - payload.remark = draft.trim() === "" ? undefined : draft; - } else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) { - payload[key] = parseNumeric(key, draft); - } else if (key === "isReserve") { - const normalized = draft.trim().toLowerCase(); - payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized); - } else { - payload[key] = draft.trim() === "" ? undefined : draft; - } - await updateCircuitById(circuitId, payload); + const result = await updateCircuitById( + projectId, + getExpectedProjectRevision(), + circuitId, + buildCircuitEditPatch(key, draft) + ); + applyProjectCommandResult(result); } - // Maps generic grid cell edits to device-row PATCH payload fields. + // Device-row commands derive local ProjectDevice override metadata server-side. async function patchDeviceRow(rowId: string, key: CellKey, draft: string) { - const payload: Record = {}; - if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) { - payload[key] = parseNumeric(key, draft); - } else if (key === "roomSummary") { - const trimmed = draft.trim(); - if (!trimmed) { - payload.roomNumberSnapshot = undefined; - payload.roomNameSnapshot = undefined; - } else { - const firstSpace = trimmed.indexOf(" "); - if (firstSpace > 0) { - payload.roomNumberSnapshot = trimmed.slice(0, firstSpace).trim(); - payload.roomNameSnapshot = trimmed.slice(firstSpace + 1).trim(); - } else { - payload.roomNumberSnapshot = trimmed; - payload.roomNameSnapshot = undefined; - } - } - } else { - payload[key] = draft.trim() === "" ? undefined : draft; - } - await updateCircuitDeviceRowById(rowId, payload); + const result = await updateCircuitDeviceRowById( + projectId, + getExpectedProjectRevision(), + rowId, + buildDeviceRowEditPatch(key, draft) + ); + applyProjectCommandResult(result); } // Creates a real circuit from virtual placeholder row. @@ -1204,6 +1244,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const isDeviceField = deviceFieldKeys.has(key); if (isDeviceField) { + const editValues = createValuesFromEditPatch( + buildDeviceRowEditPatch(key, draft) + ); const created = await createCircuitWithDeviceRows(projectId, circuitListId, { circuit: { sectionId, @@ -1221,37 +1264,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, + ...editValues, }, ], }); const createdCircuit = created.circuit; - const createdRow = created.deviceRows[0]; - await patchDeviceRow(createdRow.id, key, draft); return { rowKey: `circuitCompact:${createdCircuit.id}`, cellKey: key }; } + const editValues = createValuesFromEditPatch( + buildCircuitEditPatch(key, draft) + ); const createdCircuit = (await createCircuit(projectId, circuitListId, { sectionId, equipmentIdentifier: next.nextIdentifier, displayName: "Neuer Stromkreis", sortOrder, isReserve: true, + ...editValues, })) as CircuitTreeCircuitDto; - await patchCircuit(createdCircuit.id, key, draft); return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; } - // Reads current persisted value into editable draft text for reversible edit commands. - function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) { - if (kind === "deviceField" && row.device) { - return String(getDeviceValue(row.device, cellKey) ?? ""); - } - if (kind === "circuitField" && row.circuit) { - return String(getCircuitValue(row.circuit, cellKey) ?? ""); - } - return ""; - } - // Commits the active editor input through command history so edits remain undoable. async function commitEdit(direction: SaveDirection = "stay") { if (!editingCell) { @@ -1316,7 +1350,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } if (cell.kind === "circuitField" && row.circuit) { - const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind); const next = editingCell.draft; const command: HistoryCommand = { label: "Stromkreisfeld bearbeiten", @@ -1324,9 +1357,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await patchCircuit(row.circuit!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, - undo: async () => { - await patchCircuit(row.circuit!.id, editingCell.cellKey, previous); - return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey }); + undo: async () => + buildSelectionIntent({ + rowKey: row.rowKey, + cellKey: editingCell.cellKey, + }), + persistent: { + undoIntent: () => + buildSelectionIntent({ + rowKey: row.rowKey, + cellKey: editingCell.cellKey, + }), + redoIntent: nextSelectionIntent, }, }; setEditingCell(null); @@ -1335,7 +1377,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } if (cell.kind === "deviceField" && row.device) { - const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind); const next = editingCell.draft; const command: HistoryCommand = { label: "Gerätefeld bearbeiten", @@ -1343,9 +1384,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str await patchDeviceRow(row.device!.id, editingCell.cellKey, next); return nextSelectionIntent(); }, - undo: async () => { - await patchDeviceRow(row.device!.id, editingCell.cellKey, previous); - return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey }); + undo: async () => + buildSelectionIntent({ + rowKey: `device:${row.device!.id}`, + cellKey: editingCell.cellKey, + }), + persistent: { + undoIntent: () => + buildSelectionIntent({ + rowKey: `device:${row.device!.id}`, + cellKey: editingCell.cellKey, + }), + redoIntent: nextSelectionIntent, }, }; setEditingCell(null); @@ -1359,6 +1409,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const command: HistoryCommand = { label: "Gerätezeile im Reservestromkreis erstellen", redo: async () => { + const editValues = createValuesFromEditPatch( + buildDeviceRowEditPatch(editingCell.cellKey, next) + ); const created = (await createCircuitDeviceRow(row.circuit!.id, { name: "Reserveverbraucher", displayName: "Reserveverbraucher", @@ -1367,9 +1420,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str powerPerUnit: 0, simultaneityFactor: 1, cosPhi: 1, + ...editValues, })) as { id: string }; createdRowId = created.id; - await patchDeviceRow(created.id, editingCell.cellKey, next); targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; return nextSelectionIntent(); }, diff --git a/src/frontend/types.ts b/src/frontend/types.ts index fc44e9b..e5adc51 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -240,6 +240,7 @@ export interface CircuitTreeMigrationReportDto { export interface CircuitTreeResponseDto { circuitListId: string; + currentRevision: number; sections: CircuitTreeSectionDto[]; migrationReport?: CircuitTreeMigrationReportDto; } @@ -264,8 +265,6 @@ export interface CreateCircuitInputDto { remark?: string; } -export type UpdateCircuitInputDto = Partial; - export interface CreateCircuitDeviceRowInputDto { linkedProjectDeviceId?: string; name: string; @@ -287,5 +286,4 @@ export interface CreateCircuitDeviceRowInputDto { sortOrder?: number; } -export type UpdateCircuitDeviceRowInputDto = Partial; import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields"; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 32b4ef3..adad7e7 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -20,11 +20,17 @@ import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CreateCircuitInputDto, - UpdateCircuitInputDto, CreateCircuitDeviceRowInputDto, - UpdateCircuitDeviceRowInputDto, } from "../types"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; +import { + createCircuitUpdateProjectCommand, + type CircuitUpdatePatch, +} from "../../domain/models/circuit-project-command.model"; +import { + createCircuitDeviceRowUpdateProjectCommand, + type CircuitDeviceRowUpdatePatch, +} from "../../domain/models/circuit-device-row-project-command.model"; async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -180,11 +186,18 @@ export function createCircuitWithDeviceRows( ); } -export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) { - return request(`/api/circuits/${circuitId}`, { - method: "PATCH", - body: JSON.stringify(input), - }); +export function updateCircuitById( + projectId: string, + expectedRevision: number, + circuitId: string, + input: CircuitUpdatePatch +) { + return executeProjectCommand( + projectId, + expectedRevision, + createCircuitUpdateProjectCommand(circuitId, input), + "Stromkreisfeld bearbeiten" + ); } export function deleteCircuitById(circuitId: string) { @@ -206,11 +219,18 @@ export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDe }); } -export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) { - return request(`/api/circuit-device-rows/${rowId}`, { - method: "PATCH", - body: JSON.stringify(input), - }); +export function updateCircuitDeviceRowById( + projectId: string, + expectedRevision: number, + rowId: string, + input: CircuitDeviceRowUpdatePatch +) { + return executeProjectCommand( + projectId, + expectedRevision, + createCircuitDeviceRowUpdateProjectCommand(rowId, input), + "Gerätezeilenfeld bearbeiten" + ); } export function deleteCircuitDeviceRowById(rowId: string) { diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index b34233c..41d0fc2 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -1,4 +1,6 @@ import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js"; +import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js"; +import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js"; export type CellKey = | "equipmentIdentifier" @@ -189,6 +191,78 @@ export function parseNumeric(cellKey: CellKey, draft: string): number | undefine return parsed; } +export function buildCircuitEditPatch( + key: CellKey, + draft: string +): CircuitUpdatePatch { + if (key === "protectionSummary" || key === "cableSummary") { + return { remark: draft.trim() || null }; + } + if ( + key === "protectionRatedCurrent" || + key === "cableLength" || + key === "voltage" + ) { + return { [key]: parseNumeric(key, draft) ?? null }; + } + if (key === "isReserve") { + const normalized = draft.trim().toLowerCase(); + return { + isReserve: ["1", "true", "yes", "ja"].includes(normalized), + }; + } + if (key === "equipmentIdentifier") { + return { equipmentIdentifier: draft.trim() }; + } + return { [key]: draft.trim() || null } as CircuitUpdatePatch; +} + +export function buildDeviceRowEditPatch( + key: CellKey, + draft: string +): CircuitDeviceRowUpdatePatch { + if ( + key === "quantity" || + key === "powerPerUnit" || + key === "simultaneityFactor" + ) { + const value = parseNumeric(key, draft); + if (value === undefined) { + throw new Error("Dieses Zahlenfeld darf nicht leer sein."); + } + return { [key]: value }; + } + if (key === "cosPhi") { + return { cosPhi: parseNumeric(key, draft) ?? null }; + } + if (key === "roomSummary") { + const trimmed = draft.trim(); + if (!trimmed) { + return { + roomNumberSnapshot: null, + roomNameSnapshot: null, + }; + } + const firstSpace = trimmed.indexOf(" "); + return firstSpace > 0 + ? { + roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(), + roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null, + } + : { + roomNumberSnapshot: trimmed, + roomNameSnapshot: null, + }; + } + if (key === "technicalName") { + return { name: draft.trim() }; + } + if (key === "displayName") { + return { displayName: draft.trim() }; + } + return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch; +} + export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue { switch (key) { case "technicalName": return device.name; diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts index 7a587ad..2befe78 100644 --- a/src/server/controllers/circuit-device-row.controller.ts +++ b/src/server/controllers/circuit-device-row.controller.ts @@ -4,7 +4,6 @@ import { createCircuitDeviceRowSchema, moveCircuitDeviceRowsBulkSchema, moveCircuitDeviceRowSchema, - updateCircuitDeviceRowSchema, } from "../../shared/validation/circuit.schemas.js"; export async function createCircuitDeviceRow(req: Request, res: Response) { @@ -26,25 +25,6 @@ export async function createCircuitDeviceRow(req: Request, res: Response) { } } -export async function updateCircuitDeviceRow(req: Request, res: Response) { - const { rowId } = req.params; - if (typeof rowId !== "string") { - return res.status(400).json({ error: "Invalid rowId" }); - } - - const parsed = updateCircuitDeviceRowSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const updated = await circuitWriteService.updateDeviceRow(rowId, parsed.data); - return res.json(updated); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update device row." }); - } -} - export async function deleteCircuitDeviceRow(req: Request, res: Response) { const { rowId } = req.params; if (typeof rowId !== "string") { diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts index 398a528..302f57a 100644 --- a/src/server/controllers/circuit-tree.controller.ts +++ b/src/server/controllers/circuit-tree.controller.ts @@ -3,6 +3,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; +import { ProjectRepository } from "../../db/repositories/project.repository.js"; import { calculateCircuitTotalPower, calculateRowTotalPower, @@ -13,6 +14,7 @@ const circuitListRepository = new CircuitListRepository(); const circuitSectionRepository = new CircuitSectionRepository(); const circuitRepository = new CircuitRepository(); const circuitDeviceRowRepository = new CircuitDeviceRowRepository(); +const projectRepository = new ProjectRepository(); export function isMissingCircuitTreeSchemaError(error: unknown): boolean { if (!(error instanceof Error)) { @@ -35,6 +37,10 @@ export async function getCircuitTree(req: Request, res: Response) { if (!list) { return res.status(404).json({ error: "Circuit list not found" }); } + const project = await projectRepository.findById(projectId); + if (!project) { + return res.status(404).json({ error: "Project not found" }); + } try { const sections = await circuitSectionRepository.listByCircuitList(circuitListId); @@ -52,6 +58,7 @@ export async function getCircuitTree(req: Request, res: Response) { const tree: CircuitTreeResponse = { circuitListId, + currentRevision: project.currentRevision, sections: sections.map((section) => ({ id: section.id, key: section.key, @@ -123,6 +130,7 @@ export async function getCircuitTree(req: Request, res: Response) { if (isMissingCircuitTreeSchemaError(error)) { return res.json({ circuitListId, + currentRevision: project.currentRevision, sections: [], warning: "Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).", diff --git a/src/server/controllers/circuit.controller.ts b/src/server/controllers/circuit.controller.ts index c29761e..6a31923 100644 --- a/src/server/controllers/circuit.controller.ts +++ b/src/server/controllers/circuit.controller.ts @@ -3,7 +3,6 @@ import { circuitWriteService } from "../composition/circuit-write-service.js"; import { createCircuitSchema, createCircuitWithDeviceRowsSchema, - updateCircuitSchema, } from "../../shared/validation/circuit.schemas.js"; export async function createCircuit(req: Request, res: Response) { @@ -53,25 +52,6 @@ export async function createCircuitWithDeviceRows(req: Request, res: Response) { } } -export async function updateCircuit(req: Request, res: Response) { - const { circuitId } = req.params; - if (typeof circuitId !== "string") { - return res.status(400).json({ error: "Invalid circuitId" }); - } - - const parsed = updateCircuitSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.flatten() }); - } - - try { - const updated = await circuitWriteService.updateCircuit(circuitId, parsed.data); - return res.json(updated); - } catch (error) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to update circuit." }); - } -} - export async function deleteCircuit(req: Request, res: Response) { const { circuitId } = req.params; if (typeof circuitId !== "string") { diff --git a/src/server/routes/circuit-device-row.routes.ts b/src/server/routes/circuit-device-row.routes.ts index cb2dbab..2e7fac2 100644 --- a/src/server/routes/circuit-device-row.routes.ts +++ b/src/server/routes/circuit-device-row.routes.ts @@ -3,12 +3,10 @@ import { deleteCircuitDeviceRow, moveCircuitDeviceRowsBulk, moveCircuitDeviceRow, - updateCircuitDeviceRow, } from "../controllers/circuit-device-row.controller.js"; export const circuitDeviceRowRouter = Router(); circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk); -circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow); circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow); circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow); diff --git a/src/server/routes/circuit.routes.ts b/src/server/routes/circuit.routes.ts index bff9a8c..378a01e 100644 --- a/src/server/routes/circuit.routes.ts +++ b/src/server/routes/circuit.routes.ts @@ -4,7 +4,6 @@ import { createCircuitWithDeviceRows, deleteCircuit, getNextCircuitIdentifier, - updateCircuit, } from "../controllers/circuit.controller.js"; import { createCircuitDeviceRow } from "../controllers/circuit-device-row.controller.js"; @@ -15,7 +14,6 @@ circuitRouter.post( "/projects/:projectId/circuit-lists/:circuitListId/circuits-with-device-rows", createCircuitWithDeviceRows ); -circuitRouter.patch("/circuits/:circuitId", updateCircuit); circuitRouter.delete("/circuits/:circuitId", deleteCircuit); circuitRouter.get("/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier); circuitRouter.post("/circuits/:circuitId/device-rows", createCircuitDeviceRow); diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts index a0976f8..43595e8 100644 --- a/src/shared/validation/circuit.schemas.ts +++ b/src/shared/validation/circuit.schemas.ts @@ -20,13 +20,6 @@ export const createCircuitSchema = z.object({ remark: z.string().optional(), }); -export const updateCircuitSchema = createCircuitSchema.partial().extend({ - sectionId: z.string().min(1).optional(), - equipmentIdentifier: z.string().min(1).optional(), - sortOrder: z.number().optional(), - isReserve: z.boolean().optional(), -}); - export const createCircuitDeviceRowSchema = z.object({ linkedProjectDeviceId: z.string().min(1).optional(), name: z.string().min(1), @@ -53,8 +46,6 @@ export const createCircuitWithDeviceRowsSchema = z.object({ deviceRows: z.array(createCircuitDeviceRowSchema).min(1), }); -export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial(); - export const moveCircuitDeviceRowSchema = z .object({ targetCircuitId: z.string().min(1).optional(), @@ -98,10 +89,8 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({ }); export type CreateCircuitInput = z.infer; -export type UpdateCircuitInput = z.infer; export type CreateCircuitDeviceRowInput = z.infer; export type CreateCircuitWithDeviceRowsInput = z.infer; -export type UpdateCircuitDeviceRowInput = z.infer; export type MoveCircuitDeviceRowInput = z.infer; export type MoveCircuitDeviceRowsBulkInput = z.infer; export type ReorderSectionCircuitsInput = z.infer; diff --git a/tests/circuit-grid-model.test.ts b/tests/circuit-grid-model.test.ts index 3b20ee9..50f2436 100644 --- a/tests/circuit-grid-model.test.ts +++ b/tests/circuit-grid-model.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { allColumns, + buildCircuitEditPatch, + buildDeviceRowEditPatch, getBlockSortValue, getCellKind, getCircuitValue, @@ -85,4 +87,38 @@ describe("circuit grid model", () => { assert.equal(parseNumeric("quantity", ""), undefined); assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/); }); + + it("builds nullable circuit command patches from grid drafts", () => { + assert.deepEqual(buildCircuitEditPatch("voltage", ""), { + voltage: null, + }); + assert.deepEqual( + buildCircuitEditPatch("controlRequirement", " DALI "), + { controlRequirement: "DALI" } + ); + assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), { + isReserve: true, + }); + }); + + it("builds device command patches with explicit clearing semantics", () => { + assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), { + roomNumberSnapshot: null, + roomNameSnapshot: null, + }); + assert.deepEqual( + buildDeviceRowEditPatch("roomSummary", "1.01 Büro"), + { + roomNumberSnapshot: "1.01", + roomNameSnapshot: "Büro", + } + ); + assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), { + name: "Leuchte", + }); + assert.throws( + () => buildDeviceRowEditPatch("quantity", ""), + /darf nicht leer sein/ + ); + }); }); diff --git a/tests/circuit-project-command.repository.test.ts b/tests/circuit-project-command.repository.test.ts index bc8a4f6..d8eed59 100644 --- a/tests/circuit-project-command.repository.test.ts +++ b/tests/circuit-project-command.repository.test.ts @@ -71,6 +71,8 @@ function createTestFixture(): TestFixture { displayName: "Licht Bestand", sortOrder: 10, protectionRatedCurrent: 10, + voltage: 230, + controlRequirement: "none", isReserve: 0, }, { @@ -111,6 +113,8 @@ describe("circuit project-command repository", () => { const command = createCircuitUpdateProjectCommand("circuit-1", { displayName: null, protectionRatedCurrent: 16, + voltage: 400, + controlRequirement: "DALI", isReserve: true, }); @@ -125,6 +129,8 @@ describe("circuit project-command repository", () => { const circuit = getCircuit(fixture.context); assert.equal(circuit.displayName, null); assert.equal(circuit.protectionRatedCurrent, 16); + assert.equal(circuit.voltage, 400); + assert.equal(circuit.controlRequirement, "DALI"); assert.equal(circuit.isReserve, 1); assert.equal(executed.revision.revisionNumber, 1); assert.deepEqual(executed.inverse.payload, { @@ -132,6 +138,8 @@ describe("circuit project-command repository", () => { changes: [ { field: "displayName", value: "Licht Bestand" }, { field: "protectionRatedCurrent", value: 10 }, + { field: "voltage", value: 230 }, + { field: "controlRequirement", value: "none" }, { field: "isReserve", value: false }, ], }); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index b0e967a..ede6af2 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -16,45 +16,6 @@ describe("circuit write service rules", () => { assert.equal(parsed.success, true); }); - it("passes voltage and control requirements through circuit updates", async () => { - let updatePayload: Record = {}; - const circuit = { - id: "c1", - circuitListId: "l1", - sectionId: "s1", - equipmentIdentifier: "-1F1", - sortOrder: 10, - isReserve: 0, - voltage: 230, - controlRequirement: "none", - }; - const service = new CircuitWriteService({ - circuitSectionRepository: { - async findById() { - return { id: "s1", circuitListId: "l1" } as never; - }, - } as never, - circuitRepository: { - async findById() { - return circuit as never; - }, - async existsByEquipmentIdentifier() { - return false; - }, - async updateFields(_id: string, payload: Record) { - updatePayload = payload; - }, - } as never, - }); - - await service.updateCircuit("c1", { voltage: 400, controlRequirement: "DALI" }); - - assert.deepEqual(updatePayload, { - voltage: 400, - controlRequirement: "DALI", - }); - }); - it("rejects duplicate equipment identifiers in same circuit list", async () => { const service = new CircuitWriteService({ circuitListRepository: { @@ -741,36 +702,4 @@ describe("circuit write service rules", () => { assert.equal(safeCalled, true); }); - it("tracks local edits on linked device rows as overridden fields", async () => { - let updatePayload: Record = {}; - const current = { - id: "r1", - circuitId: "c1", - linkedProjectDeviceId: "pd1", - name: "Luminaire", - displayName: "Local name", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - sortOrder: 10, - overriddenFields: null, - }; - const service = new CircuitWriteService({ - deviceRowRepository: { - async findById() { - return current as never; - }, - async updateFields(_rowId: string, input: Record) { - updatePayload = input; - }, - } as never, - }); - - await service.updateDeviceRow("r1", { quantity: 2 }); - - assert.deepEqual(updatePayload, { - quantity: 2, - overriddenFields: "[\"quantity\"]", - }); - }); });