From 18ca5eb3d45925c7fb5f2d14e027485f63f52b90 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 07:37:38 +0200 Subject: [PATCH] Persist circuit protection updates --- docs/current-architecture.md | 7 + docs/spec/08-current-product-backlog.md | 3 +- ...-board-components-and-protection-groups.md | 8 +- ...t-protection-project-command.repository.ts | 97 +++++++++ ...ircuit-protection-project-command.model.ts | 108 ++++++++++ ...ircuit-protection-project-command.store.ts | 24 +++ .../services/project-command.service.ts | 13 ++ .../composition/project-command-stores.ts | 4 + ...tection-project-command.repository.test.ts | 191 ++++++++++++++++++ tests/project-command.service.test.ts | 45 +++++ ...t-state-restore-command.repository.test.ts | 2 + tests/protection-device.test.ts | 1 + 12 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/db/repositories/circuit-protection-project-command.repository.ts create mode 100644 src/domain/models/circuit-protection-project-command.model.ts create mode 100644 src/domain/ports/circuit-protection-project-command.store.ts create mode 100644 tests/circuit-protection-project-command.repository.test.ts diff --git a/docs/current-architecture.md b/docs/current-architecture.md index d47765b..716238d 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -371,6 +371,13 @@ Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere Gruppen können über `circuit-group.delete` entfernt werden; für befüllte Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog gesperrt. +`circuit-protection.update` bildet die eigene persistente Schreibgrenze für +die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen +erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die +Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang +atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen; +Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt +wiederherstellen. `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 01123a7..6a1ca07 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -36,7 +36,8 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase E2b: render the structure projection in the editor grid. - [x] Phase E3a: create, edit and delete mutable group and footer components. - [x] Phase E3b1: create, rename and safely delete empty circuit groups. -- [ ] Phase E3b2: circuit-protection editing. +- [x] Phase E3b2a: persistent circuit-protection update command. +- [ ] Phase E3b2b: circuit-protection defaults on insert and editor modal. - [ ] Phase E4: group reorder, renumber, circuit moves and populated-delete warning. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. 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 20ca71f..9a44621 100644 --- a/docs/spec/09-distribution-board-components-and-protection-groups.md +++ b/docs/spec/09-distribution-board-components-and-protection-groups.md @@ -742,7 +742,9 @@ Acceptance: Status: In progress. The complete tree read model E1, pure structural projection E2a, grid rendering E2b and mutable component editing E3a are complete. Basic group management E3b1 is also complete. Circuit-protection -editing and structural drag/renumber/delete workflows remain pending. +editing now has its persistent E3b2a command boundary; insertion defaults and +the editor modal as well as structural drag/renumber/delete workflows remain +pending. - render fixed header components - render group components and circuit blocks @@ -777,6 +779,10 @@ Implemented in E1/E2a/E2b/E3a: child BMKs remain stable - an exactly empty group can be removed through the persistent group command; populated deletion stays disabled until the dedicated warning UI is present +- `circuit-protection.update` inserts or updates one validated 1:1 protection + snapshot, rejects stale state and preserves exact persistent Undo/Redo +- user commands cannot remove circuit protection; a nullable target exists + only as the inverse of adding protection to a retained circuit without it Acceptance: diff --git a/src/db/repositories/circuit-protection-project-command.repository.ts b/src/db/repositories/circuit-protection-project-command.repository.ts new file mode 100644 index 0000000..bbc38dd --- /dev/null +++ b/src/db/repositories/circuit-protection-project-command.repository.ts @@ -0,0 +1,97 @@ +import { and, eq } from "drizzle-orm"; +import { + assertCircuitProtectionUpdateProjectCommand, + createCircuitProtectionUpdateProjectCommand, + type CircuitProtectionSnapshot, +} from "../../domain/models/circuit-protection-project-command.model.js"; +import type { + CircuitProtectionProjectCommandStore, + ExecuteCircuitProtectionCommandInput, +} from "../../domain/ports/circuit-protection-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; +import { circuits } from "../schema/circuits.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; + +export class CircuitProtectionProjectCommandRepository + implements CircuitProtectionProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitProtectionCommandInput) { + return executeProjectCommandTransaction(this.database, input, (tx) => { + assertCircuitProtectionUpdateProjectCommand(input.command); + const { circuitId, expected, target } = input.command.payload; + if (input.source === "user" && target === null) { + throw new Error("User commands cannot remove circuit protection."); + } + this.assertOwnership(tx, input.projectId, circuitId); + const actual = tx + .select() + .from(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .get(); + if (!sameNullableSnapshot(expected, actual)) { + throw new Error("Circuit protection changed before update."); + } + if (target === null) { + tx.delete(circuitProtectionDevices) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .run(); + } else if (actual) { + tx.update(circuitProtectionDevices) + .set(target) + .where(eq(circuitProtectionDevices.circuitId, circuitId)) + .run(); + } else { + tx.insert(circuitProtectionDevices).values(target).run(); + } + return createCircuitProtectionUpdateProjectCommand( + circuitId, + target, + expected + ); + }); + } + + private assertOwnership( + database: AppDatabase, + projectId: string, + circuitId: string + ) { + const circuit = database + .select({ projectId: circuitLists.projectId }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where( + and( + eq(circuits.id, circuitId), + eq(circuitLists.projectId, projectId) + ) + ) + .get(); + if (!circuit) { + throw new Error("Circuit protection does not belong to project."); + } + } +} + +function sameNullableSnapshot( + expected: CircuitProtectionSnapshot | null, + actual: typeof circuitProtectionDevices.$inferSelect | undefined +) { + if (expected === null) { + return actual === undefined; + } + if (!actual) { + return false; + } + return Object.entries(expected).every( + ([key, value]) => + (actual as Record)[key] === value + ); +} diff --git a/src/domain/models/circuit-protection-project-command.model.ts b/src/domain/models/circuit-protection-project-command.model.ts new file mode 100644 index 0000000..f4604f6 --- /dev/null +++ b/src/domain/models/circuit-protection-project-command.model.ts @@ -0,0 +1,108 @@ +import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js"; +import type { + BreakerTripCharacteristic, + FuseUtilizationCategory, + ProtectionDeviceType, + RcdType, +} from "../../shared/constants/protection-device.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitProtectionUpdateCommandType = + "circuit-protection.update" as const; +export const circuitProtectionUpdateCommandSchemaVersion = 1 as const; + +export interface CircuitProtectionSnapshot { + circuitId: string; + type: ProtectionDeviceType; + ratedCurrentA: number; + fuseUtilizationCategory: FuseUtilizationCategory | null; + tripCharacteristic: BreakerTripCharacteristic | null; + rcdType: RcdType | null; + ratedResidualCurrentMa: number | null; +} + +interface CircuitProtectionUpdatePayload { + circuitId: string; + expected: CircuitProtectionSnapshot | null; + target: CircuitProtectionSnapshot | null; +} + +export interface CircuitProtectionUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitProtectionUpdateCommandSchemaVersion; + type: typeof circuitProtectionUpdateCommandType; +} + +export function createCircuitProtectionUpdateProjectCommand( + circuitId: string, + expected: CircuitProtectionSnapshot | null, + target: CircuitProtectionSnapshot | null +): CircuitProtectionUpdateProjectCommand { + const command: CircuitProtectionUpdateProjectCommand = { + schemaVersion: circuitProtectionUpdateCommandSchemaVersion, + type: circuitProtectionUpdateCommandType, + payload: { circuitId, expected, target }, + }; + assertCircuitProtectionUpdateProjectCommand(command); + return command; +} + +export function assertCircuitProtectionUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitProtectionUpdateProjectCommand { + if ( + command.schemaVersion !== circuitProtectionUpdateCommandSchemaVersion || + command.type !== circuitProtectionUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 3 + ) { + throw new Error("Unsupported circuit-protection update command."); + } + const { circuitId, expected, target } = command.payload; + if (typeof circuitId !== "string" || !circuitId.trim()) { + throw new Error("Circuit protection requires a circuit id."); + } + if (expected === null && target === null) { + throw new Error("Circuit protection update must change state."); + } + if (expected !== null) { + assertSnapshot(expected, circuitId); + } + if (target !== null) { + assertSnapshot(target, circuitId); + } + if (JSON.stringify(expected) === JSON.stringify(target)) { + throw new Error("Circuit protection update must change state."); + } +} + +function assertSnapshot(value: unknown, circuitId: string) { + if ( + !isPlainObject(value) || + Object.keys(value).length !== 7 || + value.circuitId !== circuitId + ) { + throw new Error("Circuit-protection snapshot is invalid."); + } + const result = protectionDeviceConfigurationSchema.safeParse({ + type: value.type, + ratedCurrentA: value.ratedCurrentA, + ...(value.fuseUtilizationCategory === null + ? {} + : { fuseUtilizationCategory: value.fuseUtilizationCategory }), + ...(value.tripCharacteristic === null + ? {} + : { tripCharacteristic: value.tripCharacteristic }), + ...(value.rcdType === null ? {} : { rcdType: value.rcdType }), + ...(value.ratedResidualCurrentMa === null + ? {} + : { ratedResidualCurrentMa: value.ratedResidualCurrentMa }), + }); + if (!result.success) { + throw new Error("Circuit-protection configuration is invalid."); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-protection-project-command.store.ts b/src/domain/ports/circuit-protection-project-command.store.ts new file mode 100644 index 0000000..6fa6647 --- /dev/null +++ b/src/domain/ports/circuit-protection-project-command.store.ts @@ -0,0 +1,24 @@ +import type { + CircuitProtectionUpdateProjectCommand, +} from "../models/circuit-protection-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitProtectionCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitProtectionUpdateProjectCommand; +} + +export interface CircuitProtectionProjectCommandStore { + execute(input: ExecuteCircuitProtectionCommandInput): { + revision: AppendedProjectRevision; + inverse: CircuitProtectionUpdateProjectCommand; + }; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 48a8526..8d0fc62 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -19,6 +19,10 @@ import { assertCircuitUpdateProjectCommand, circuitUpdateCommandType, } from "../models/circuit-project-command.model.js"; +import { + assertCircuitProtectionUpdateProjectCommand, + circuitProtectionUpdateCommandType, +} from "../models/circuit-protection-project-command.model.js"; import { assertCircuitSectionReorderProjectCommand, circuitSectionReorderCommandType, @@ -95,6 +99,7 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; +import type { CircuitProtectionProjectCommandStore } from "../ports/circuit-protection-project-command.store.js"; import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js"; import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js"; import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; @@ -177,6 +182,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore, private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore, private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore, + private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -421,6 +427,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitProtectionUpdateCommandType: { + assertCircuitProtectionUpdateProjectCommand(input.command); + return this.circuitProtectionStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index f42a606..51a0b0e 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js"; @@ -30,6 +31,8 @@ export const circuitDeviceRowMoveProjectCommandStore = new CircuitDeviceRowMoveProjectCommandRepository(db); export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); +export const circuitProtectionProjectCommandStore = + new CircuitProtectionProjectCommandRepository(db); export const circuitGroupStructureProjectCommandStore = new CircuitGroupStructureProjectCommandRepository(db); export const circuitGroupRenumberProjectCommandStore = @@ -79,5 +82,6 @@ export const projectCommandService = new ProjectCommandService( circuitGroupRenumberProjectCommandStore, circuitGroupMoveProjectCommandStore, circuitGroupSubtreeProjectCommandStore, + circuitProtectionProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-protection-project-command.repository.test.ts b/tests/circuit-protection-project-command.repository.test.ts new file mode 100644 index 0000000..a21c3f7 --- /dev/null +++ b/tests/circuit-protection-project-command.repository.test.ts @@ -0,0 +1,191 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createCircuitProtectionUpdateProjectCommand, + type CircuitProtectionSnapshot, +} from "../src/domain/models/circuit-protection-project-command.model.js"; +import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Projekt" }, + { id: "project-2", name: "Fremdprojekt" }, + ]) + .run(); + new DistributionBoardFixtureRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const list = context.db.select().from(circuitLists).get(); + assert.ok(list); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, list.id)) + .get(); + assert.ok(section); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1F1.1", + sortOrder: 10, + }) + .run(); + return context; +} + +const protection: CircuitProtectionSnapshot = { + circuitId: "circuit-1", + type: "LS", + ratedCurrentA: 10, + fuseUtilizationCategory: null, + tripCharacteristic: "B", + rcdType: null, + ratedResidualCurrentMa: null, +}; + +describe("circuit-protection project command", () => { + it("inserts, updates and restores protection through exact inverse commands", () => { + const context = createTestDatabase(); + try { + const repository = new CircuitProtectionProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + const inserted = repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + null, + protection + ), + }); + assert.deepEqual( + context.db.select().from(circuitProtectionDevices).get(), + protection + ); + const target = { ...protection, ratedCurrentA: 16 }; + const updated = repository.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + protection, + target + ), + }); + assert.equal( + context.db.select().from(circuitProtectionDevices).get() + ?.ratedCurrentA, + 16 + ); + const updateUndo = history.getNextCommand("project-1", "undo"); + assert.ok(updateUndo); + repository.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "undo", + historyTargetChangeSetId: updateUndo.changeSetId, + command: updated.inverse, + }); + assert.deepEqual( + context.db.select().from(circuitProtectionDevices).get(), + protection + ); + const insertUndo = history.getNextCommand("project-1", "undo"); + assert.ok(insertUndo); + repository.execute({ + projectId: "project-1", + expectedRevision: 3, + source: "undo", + historyTargetChangeSetId: insertUndo.changeSetId, + command: inserted.inverse, + }); + assert.equal( + context.db.select().from(circuitProtectionDevices).get(), + undefined + ); + } finally { + context.close(); + } + }); + + it("rejects stale state, foreign ownership and user removal", () => { + const context = createTestDatabase(); + try { + const repository = new CircuitProtectionProjectCommandRepository( + context.db + ); + assert.throws( + () => + repository.execute({ + projectId: "project-2", + expectedRevision: 0, + source: "user", + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + null, + protection + ), + }), + /does not belong/ + ); + context.db.insert(circuitProtectionDevices).values(protection).run(); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + null, + protection + ), + }), + /changed before/ + ); + assert.throws( + () => + repository.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + protection, + null + ), + }), + /cannot remove/ + ); + } finally { + context.close(); + } + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index b1988e5..33a9821 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit- import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; @@ -29,6 +30,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; @@ -38,6 +40,7 @@ import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { floors } from "../src/db/schema/floors.js"; import { rooms } from "../src/db/schema/rooms.js"; +import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; @@ -149,6 +152,7 @@ function createService(context: DatabaseContext) { new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db), new CircuitGroupSubtreeProjectCommandRepository(context.db), + new CircuitProtectionProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } @@ -170,6 +174,47 @@ function getRowQuantity(context: DatabaseContext) { } describe("project command service", () => { + it("dispatches circuit-protection updates through project history", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const protection = { + circuitId: "circuit-1", + type: "LS" as const, + ratedCurrentA: 10, + fuseUtilizationCategory: null, + tripCharacteristic: "B" as const, + rcdType: null, + ratedResidualCurrentMa: null, + }; + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitProtectionUpdateProjectCommand( + "circuit-1", + null, + protection + ), + }); + assert.deepEqual( + context.db.select().from(circuitProtectionDevices).get(), + protection + ); + service.undo({ projectId: "project-1", expectedRevision: 1 }); + assert.equal( + context.db.select().from(circuitProtectionDevices).get(), + undefined + ); + service.redo({ projectId: "project-1", expectedRevision: 2 }); + assert.deepEqual( + context.db.select().from(circuitProtectionDevices).get(), + protection + ); + } finally { + context.close(); + } + }); + it("dispatches supported commands and persists undo/redo across service instances", () => { const context = createTestDatabase(); try { diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts index 26c8c51..5bf1ac6 100644 --- a/tests/project-state-restore-command.repository.test.ts +++ b/tests/project-state-restore-command.repository.test.ts @@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit- import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js"; import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js"; import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js"; import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js"; @@ -203,6 +204,7 @@ function createService(context: DatabaseContext) { new CircuitGroupRenumberProjectCommandRepository(context.db), new CircuitGroupMoveProjectCommandRepository(context.db), new CircuitGroupSubtreeProjectCommandRepository(context.db), + new CircuitProtectionProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/protection-device.test.ts b/tests/protection-device.test.ts index 06d13b2..d89ddb3 100644 --- a/tests/protection-device.test.ts +++ b/tests/protection-device.test.ts @@ -13,6 +13,7 @@ import { createDefaultGroupRcd, } from "../src/domain/services/protection-device-defaults.js"; import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js"; +import "./circuit-protection-project-command.repository.test.js"; describe("protection device catalog", () => { it("contains exactly the agreed protection device types", () => {