Add component structure commands

This commit is contained in:
2026-07-30 19:38:48 +02:00
parent 9d4d4082fe
commit 604604f056
15 changed files with 1058 additions and 5 deletions
+6
View File
@@ -301,6 +301,12 @@ inverse removes only the same unchanged and still-empty structure; the POST
route requires `expectedRevision` and returns the updated history state.
Stored schema-version 1 and 2 setup commands remain executable with their four
legacy sections and without invented components.
Mutable group-protection and auxiliary distribution-board components use
`distribution-board-component.insert` and
`distribution-board-component.delete`. Their complete snapshots include the
optional one-to-one protection-device state, and deletion rejects stale data.
Fixed main-switch and surge-protection header roles are excluded from these
general component commands.
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
+13 -2
View File
@@ -285,6 +285,16 @@ nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
Freigabe der Netzart in den Projekteinstellungen.
Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier
Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar.
`distribution-board-component.insert` und
`distribution-board-component.delete` versionieren die Anlage und Entfernung
veränderlicher Gruppen-Schutzgeräte und zusätzlicher Verteilergeräte. Der
vollständige Snapshot enthält die stabile Komponenten-UUID und, für
Gruppen-Schutzgeräte, die getrennte 1:1-Schutzgerätekonfiguration. Eigentum an
Projekt, Stromkreisliste und Gruppe sowie der unveränderte Löschzustand werden
innerhalb derselben Transaktion geprüft. Die festen Kopfkomponenten
Hauptschalter und Überspannungsableiter sind von diesen allgemeinen Commands
ausgeschlossen. API- und Editorintegration folgen in den nächsten
Arbeitspaketen.
`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
@@ -355,8 +365,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7; Command- und UI-Integration folgen in den nächsten
abgegrenzten Arbeitspaketen.
Snapshot-Schema 7. Persistente Insert/Delete-Commands für veränderliche
Verteilerkomponenten sind integriert; weitere Command- und UI-Schritte
folgen in abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+4 -1
View File
@@ -20,7 +20,10 @@ requirements and intended sequencing, not proof of implementation.
mapping.
- [x] Phase C1: versioned new-board command with three groups and fixed header
components.
- [ ] Phase C2: component and group CRUD/reorder commands.
- [x] Phase C2a1: persistent insert/delete commands for mutable group
protection and auxiliary components.
- [ ] Phase C2a2: component update and reorder commands.
- [ ] Phase C2b: group CRUD and reorder commands.
- [ ] Phase D: group numbering, moves and destructive operations.
- [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification.
@@ -593,8 +593,9 @@ Acceptance:
### C. Persistent Commands and Board Defaults
Status: In progress. New-board defaults C1 are complete; component and group
management commands C2 are pending.
Status: In progress. New-board defaults C1 and component insert/delete commands
C2a1 are complete. Component update/reorder and group management remain
pending.
- extend `distribution-board.insert` with fixed components and three groups
- implement component and group CRUD commands
@@ -612,6 +613,19 @@ Implemented in C1:
- stored schema versions 1 and 2 retain four legacy sections and receive no
invented components
Implemented in C2a1:
- `distribution-board-component.insert` and
`distribution-board-component.delete` persist complete component snapshots
with their optional one-to-one protection-device configuration
- mutable group protection components validate their group ownership and
protection-device combination before writing
- auxiliary components remain footer entries without protection-device data
- fixed header roles are excluded from these general CRUD commands
- deletion requires an exact unchanged snapshot; Undo/Redo restores the same
component UUID and a late revision/history failure rolls back the complete
write
Acceptance:
- new boards contain the agreed fixed structure
@@ -0,0 +1,193 @@
import { and, eq } from "drizzle-orm";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
createDistributionBoardComponentDeleteProjectCommand,
createDistributionBoardComponentInsertProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
type DistributionBoardComponentSnapshot,
type DistributionBoardComponentStructureProjectCommand,
} from "../../domain/models/distribution-board-component-structure-project-command.model.js";
import type {
DistributionBoardComponentStructureProjectCommandStore,
ExecuteDistributionBoardComponentStructureCommandInput,
} from "../../domain/ports/distribution-board-component-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class DistributionBoardComponentStructureProjectCommandRepository
implements DistributionBoardComponentStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) =>
this.applyCommand(
tx,
input.projectId,
input.command
)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: DistributionBoardComponentStructureProjectCommand
): DistributionBoardComponentStructureProjectCommand {
if (command.type === distributionBoardComponentInsertCommandType) {
assertDistributionBoardComponentInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === distributionBoardComponentDeleteCommandType) {
assertDistributionBoardComponentDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createDistributionBoardComponentInsertProjectCommand(
command.payload.snapshot
);
}
throw new Error(
"Unsupported distribution-board component structure command."
);
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const existing = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
if (existing) {
throw new Error("Distribution-board component id already exists.");
}
database
.insert(distributionBoardComponents)
.values(snapshot.component)
.run();
if (snapshot.protectionDevice !== null) {
database
.insert(distributionBoardComponentProtectionDevices)
.values(snapshot.protectionDevice)
.run();
}
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
this.assertOwnership(database, projectId, snapshot);
const component = database
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.get();
const protection = database
.select()
.from(distributionBoardComponentProtectionDevices)
.where(
eq(
distributionBoardComponentProtectionDevices.componentId,
snapshot.component.id
)
)
.get();
if (
!component ||
!sameRecord(snapshot.component, component) ||
!sameNullableRecord(snapshot.protectionDevice, protection)
) {
throw new Error(
"Distribution-board component changed before deletion."
);
}
const deleted = database
.delete(distributionBoardComponents)
.where(
and(
eq(distributionBoardComponents.id, component.id),
eq(
distributionBoardComponents.circuitListId,
snapshot.component.circuitListId
)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error(
"Distribution-board component could not be deleted."
);
}
}
private assertOwnership(
database: AppDatabase,
projectId: string,
snapshot: DistributionBoardComponentSnapshot
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, snapshot.component.circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error(
"Distribution-board component circuit list does not belong to project."
);
}
if (snapshot.component.sectionId !== null) {
const section = database
.select({ circuitListId: circuitSections.circuitListId })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.component.sectionId))
.get();
if (
!section ||
section.circuitListId !== snapshot.component.circuitListId
) {
throw new Error(
"Distribution-board component section does not belong to circuit list."
);
}
}
}
}
function sameNullableRecord(
expected: object | null,
actual: object | undefined
) {
return expected === null
? actual === undefined
: actual !== undefined && sameRecord(expected, actual);
}
function sameRecord(expected: object, actual: object) {
const expectedEntries = Object.entries(expected);
const actualRecord = actual as Record<string, unknown>;
return (
expectedEntries.length === Object.keys(actual).length &&
expectedEntries.every(
([key, value]) => actualRecord[key] === value
)
);
}
@@ -0,0 +1,254 @@
import { distributionBoardComponentPlacements } from "../../shared/constants/distribution-board-component.js";
import type { DistributionBoardComponentPlacement } from "../../shared/constants/distribution-board-component.js";
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 distributionBoardComponentInsertCommandType =
"distribution-board-component.insert" as const;
export const distributionBoardComponentDeleteCommandType =
"distribution-board-component.delete" as const;
export const distributionBoardComponentStructureCommandSchemaVersion =
1 as const;
type MutableDistributionBoardComponentRole =
| "group_upstream_protection"
| "group_residual_current_protection"
| "auxiliary";
export interface DistributionBoardComponentSnapshot {
component: {
id: string;
circuitListId: string;
sectionId: string | null;
equipmentIdentifier: string;
name: string;
role: MutableDistributionBoardComponentRole;
placement: DistributionBoardComponentPlacement;
sortOrder: number;
};
protectionDevice: {
componentId: string;
type: ProtectionDeviceType;
ratedCurrentA: number;
fuseUtilizationCategory: FuseUtilizationCategory | null;
tripCharacteristic: BreakerTripCharacteristic | null;
rcdType: RcdType | null;
ratedResidualCurrentMa: number | null;
} | null;
}
interface DistributionBoardComponentStructurePayload {
snapshot: DistributionBoardComponentSnapshot;
}
export interface DistributionBoardComponentInsertProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentInsertCommandType;
}
export interface DistributionBoardComponentDeleteProjectCommand
extends SerializedProjectCommand<DistributionBoardComponentStructurePayload> {
schemaVersion: typeof distributionBoardComponentStructureCommandSchemaVersion;
type: typeof distributionBoardComponentDeleteCommandType;
}
export type DistributionBoardComponentStructureProjectCommand =
| DistributionBoardComponentInsertProjectCommand
| DistributionBoardComponentDeleteProjectCommand;
export function createDistributionBoardComponentInsertProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentInsertProjectCommand {
const command: DistributionBoardComponentInsertProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentInsertCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentInsertProjectCommand(command);
return command;
}
export function createDistributionBoardComponentDeleteProjectCommand(
snapshot: DistributionBoardComponentSnapshot
): DistributionBoardComponentDeleteProjectCommand {
const command: DistributionBoardComponentDeleteProjectCommand = {
schemaVersion:
distributionBoardComponentStructureCommandSchemaVersion,
type: distributionBoardComponentDeleteCommandType,
payload: { snapshot },
};
assertDistributionBoardComponentDeleteProjectCommand(command);
return command;
}
export function assertDistributionBoardComponentInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentInsertProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentInsertCommandType
);
}
export function assertDistributionBoardComponentDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is DistributionBoardComponentDeleteProjectCommand {
assertStructureCommand(
command,
distributionBoardComponentDeleteCommandType
);
}
function assertStructureCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof distributionBoardComponentInsertCommandType
| typeof distributionBoardComponentDeleteCommandType
) {
if (
command.schemaVersion !==
distributionBoardComponentStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error(
"Unsupported distribution-board component structure command."
);
}
assertDistributionBoardComponentSnapshot(command.payload.snapshot);
}
export function assertDistributionBoardComponentSnapshot(
value: unknown
): asserts value is DistributionBoardComponentSnapshot {
if (
!isPlainObject(value) ||
Object.keys(value).length !== 2 ||
!isPlainObject(value.component)
) {
throw new Error(
"Distribution-board component snapshot is invalid."
);
}
const component = value.component;
if (Object.keys(component).length !== 8) {
throw new Error(
"Distribution-board component snapshot is incomplete."
);
}
for (const field of [
"id",
"circuitListId",
"equipmentIdentifier",
"name",
] as const) {
assertNonEmptyString(component[field], `component.${field}`);
}
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection" &&
component.role !== "auxiliary"
) {
throw new Error(
"Fixed distribution-board components cannot use component CRUD commands."
);
}
if (
!distributionBoardComponentPlacements.includes(
component.placement as DistributionBoardComponentPlacement
) ||
!Number.isFinite(component.sortOrder)
) {
throw new Error(
"Distribution-board component placement or sort order is invalid."
);
}
const groupComponent = component.role !== "auxiliary";
if (
groupComponent !==
(component.placement === "group" &&
typeof component.sectionId === "string" &&
Boolean(component.sectionId.trim()))
) {
throw new Error(
"Group protection components require group placement and a section."
);
}
if (
!groupComponent &&
(component.placement !== "footer" ||
component.sectionId !== null)
) {
throw new Error(
"Auxiliary components require footer placement without a section."
);
}
if (groupComponent) {
if (!isPlainObject(value.protectionDevice)) {
throw new Error(
"Group protection components require protection data."
);
}
assertProtectionDevice(
value.protectionDevice,
component.id as string
);
} else if (value.protectionDevice !== null) {
throw new Error(
"Auxiliary components cannot contain protection data."
);
}
}
function assertProtectionDevice(
value: Record<string, unknown>,
componentId: string
) {
if (
Object.keys(value).length !== 7 ||
value.componentId !== componentId
) {
throw new Error(
"Distribution-board component 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(
"Distribution-board component protection configuration is invalid."
);
}
}
function assertNonEmptyString(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,24 @@
import type { DistributionBoardComponentStructureProjectCommand } from "../models/distribution-board-component-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteDistributionBoardComponentStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: DistributionBoardComponentStructureProjectCommand;
}
export interface DistributionBoardComponentStructureProjectCommandStore {
execute(
input: ExecuteDistributionBoardComponentStructureCommandInput
): {
revision: AppendedProjectRevision;
inverse: DistributionBoardComponentStructureProjectCommand;
};
}
@@ -41,6 +41,12 @@ import {
assertDistributionBoardUpdateProjectCommand,
distributionBoardUpdateCommandType,
} from "../models/distribution-board-project-command.model.js";
import {
assertDistributionBoardComponentDeleteProjectCommand,
assertDistributionBoardComponentInsertProjectCommand,
distributionBoardComponentDeleteCommandType,
distributionBoardComponentInsertCommandType,
} from "../models/distribution-board-component-structure-project-command.model.js";
import {
assertDistributionBoardDeleteProjectCommand,
assertDistributionBoardInsertProjectCommand,
@@ -91,6 +97,7 @@ import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { DistributionBoardStructureProjectCommandStore } from "../ports/distribution-board-structure-project-command.store.js";
import type { DistributionBoardComponentStructureProjectCommandStore } from "../ports/distribution-board-component-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
@@ -135,6 +142,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -296,6 +304,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case distributionBoardComponentInsertCommandType: {
assertDistributionBoardComponentInsertProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case distributionBoardComponentDeleteCommandType: {
assertDistributionBoardComponentDeleteProjectCommand(
input.command
);
return this.distributionBoardComponentStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -33,6 +33,8 @@ const commandTypeLabels: Record<string, string> = {
"distribution-board.insert": "Verteilung angelegt",
"distribution-board.update": "Verteilung bearbeitet",
"distribution-board.delete": "Verteilung entfernt",
"distribution-board-component.insert": "Verteilergerät angelegt",
"distribution-board-component.delete": "Verteilergerät entfernt",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -7,6 +7,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../../db/reposito
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardStructureProjectCommandRepository } from "../../db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../../db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../../db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
@@ -27,6 +28,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore =
new DistributionBoardComponentStructureProjectCommandRepository(db);
export const projectLocationStructureProjectCommandStore =
new ProjectLocationStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
@@ -59,5 +62,6 @@ export const projectCommandService = new ProjectCommandService(
projectDeviceRowSyncProjectCommandStore,
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore,
projectHistoryStore
);
@@ -0,0 +1,433 @@
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 { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createDistributionBoardComponentInsertProjectCommand,
type DistributionBoardComponentSnapshot,
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.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");
return context;
}
function getListAndSection(context: DatabaseContext) {
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);
return { list, section };
}
describe("distribution-board component structure project command", () => {
it("inserts an auxiliary component and preserves it through undo and redo", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-aux",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K1",
name: "KNX Schaltaktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
const command =
createDistributionBoardComponentInsertProjectCommand(snapshot);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get(),
snapshot.component
);
const undo = history.getNextCommand("project-1", "undo");
assert.ok(undo);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undo.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get(),
undefined
);
const redo = history.getNextCommand("project-1", "redo");
assert.ok(redo);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: redo.changeSetId,
command,
});
assert.ok(
context.db
.select()
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.id, "component-aux"))
.get()
);
} finally {
context.close();
}
});
it("stores and restores a group RCD with its protection data atomically", () => {
const context = createTestDatabase();
try {
const { list, section } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-rcd",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
protectionDevice: {
componentId: "component-rcd",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
});
assert.deepEqual(
context.db
.select()
.from(distributionBoardComponentProtectionDevices)
.get(),
snapshot.protectionDevice
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db
.select()
.from(distributionBoardComponentProtectionDevices)
.get(),
undefined
);
} finally {
context.close();
}
});
it("rejects fixed roles, foreign sections, duplicate BMKs and stale revisions", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
assert.throws(
() =>
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "fixed",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-Q1",
name: "Hauptschalter",
role: "main_switch",
placement: "header",
sortOrder: 10,
} as never,
protectionDevice: null,
}),
/Fixed distribution-board components/
);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "duplicate",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: " -q0 ",
name: "Doppelt",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const foreignBoard = new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections(
"project-2",
"UV fremd"
);
const foreignSection = context.db
.select()
.from(circuitSections)
.where(
eq(circuitSections.circuitListId, foreignBoard.id)
)
.get();
assert.ok(foreignSection);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "foreign-section",
circuitListId: list.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1Q2.0",
name: "Fremder Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
protectionDevice: {
componentId: "foreign-section",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
}),
}),
/section does not belong/
);
const staleSnapshot: DistributionBoardComponentSnapshot = {
component: {
...snapshot.component,
id: "stale",
equipmentIdentifier: "-K-stale",
},
protectionDevice: null,
};
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
staleSnapshot
),
}),
ProjectRevisionConflictError
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
}),
/UNIQUE constraint failed/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
0
);
} finally {
context.close();
}
});
it("rejects changed deletes and rolls back late history failures", () => {
const context = createTestDatabase();
try {
const { list } = getListAndSection(context);
const snapshot: DistributionBoardComponentSnapshot = {
component: {
id: "component-change",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K2",
name: "Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 20,
},
protectionDevice: null,
};
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand(
snapshot
),
});
context.db
.update(distributionBoardComponents)
.set({ name: "Direkt geändert" })
.where(eq(distributionBoardComponents.id, snapshot.component.id))
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: inserted.inverse,
}),
/changed before deletion/
);
} finally {
context.close();
}
const rollback = createTestDatabase();
try {
const { list } = getListAndSection(rollback);
rollback.sqlite.exec(`
CREATE TRIGGER fail_component_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced component history failure');
END;
`);
const repository =
new DistributionBoardComponentStructureProjectCommandRepository(
rollback.db
);
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command:
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "component-rollback",
circuitListId: list.id,
sectionId: null,
equipmentIdentifier: "-K3",
name: "Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 30,
},
protectionDevice: null,
}),
}),
/forced component history failure/
);
assert.equal(
rollback.db
.select()
.from(distributionBoardComponents)
.where(
eq(
distributionBoardComponents.id,
"component-rollback"
)
)
.get(),
undefined
);
} finally {
rollback.close();
}
});
});
@@ -7,6 +7,7 @@ import {
distributionBoardComponentRoles,
} from "../src/shared/constants/distribution-board-component.js";
import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js";
import "./distribution-board-component-structure-project-command.repository.test.js";
describe("distribution board component catalog", () => {
it("defines the agreed component roles and placement zones", () => {
+62
View File
@@ -16,6 +16,7 @@ import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/reposi
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
@@ -26,6 +27,7 @@ import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.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";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
@@ -49,6 +51,7 @@ import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
import { createDistributionBoardComponentInsertProjectCommand } from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
import {
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
@@ -122,6 +125,9 @@ function createService(context: DatabaseContext) {
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db),
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
),
new ProjectHistoryRepository(context.db)
);
}
@@ -1059,6 +1065,62 @@ describe("project command service", () => {
}
});
it("dispatches distribution-board component insertion and deletion", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const created = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command:
createDistributionBoardComponentInsertProjectCommand({
component: {
id: "component-service",
circuitListId: context.db
.select()
.from(circuitSections)
.get()!.circuitListId,
sectionId: null,
equipmentIdentifier: "-K10",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
},
protectionDevice: null,
}),
});
assert.equal(created.history.currentRevision, 1);
assert.ok(
context.db
.select()
.from(distributionBoardComponents)
.where(
eq(distributionBoardComponents.id, "component-service")
)
.get()
);
const undone = createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.equal(undone.history.currentRevision, 2);
assert.equal(
context.db
.select()
.from(distributionBoardComponents)
.where(
eq(distributionBoardComponents.id, "component-service")
)
.get(),
undefined
);
} finally {
context.close();
}
});
it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase();
try {
@@ -16,6 +16,7 @@ import { CircuitSectionReorderProjectCommandRepository } from "../src/db/reposit
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { DistributionBoardStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-structure-project-command.repository.js";
import { DistributionBoardComponentStructureProjectCommandRepository } from "../src/db/repositories/distribution-board-component-structure-project-command.repository.js";
import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
@@ -191,6 +192,9 @@ function createService(context: DatabaseContext) {
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
new ProjectSettingsProjectCommandRepository(context.db),
new ProjectStateRestoreCommandRepository(context.db),
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
),
new ProjectHistoryRepository(context.db)
);
}
+16
View File
@@ -115,6 +115,22 @@ describe("project version history presentation", () => {
),
"Projekteinstellungen bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(5, {
commandType: "distribution-board-component.insert",
})
),
"Verteilergerät angelegt"
);
assert.equal(
getProjectRevisionDescription(
revision(6, {
commandType: "distribution-board-component.delete",
})
),
"Verteilergerät entfernt"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),