Add circuit group commands

This commit is contained in:
2026-07-30 19:46:55 +02:00
parent 69b020339e
commit 1250f9a6af
16 changed files with 864 additions and 5 deletions
+5
View File
@@ -309,6 +309,11 @@ snapshots include the optional one-to-one protection-device state and reject
stale data. Updates preserve id, ownership, role and placement. Fixed
main-switch and surge-protection header roles are excluded from these general
component commands.
Empty circuit-group creation, display-name updates and deletion use
`circuit-group.insert`, `circuit-group.update` and `circuit-group.delete`.
Their snapshots keep category, positive group number and derived prefix
consistent. General deletion rejects groups containing circuits or group
components; populated deletion remains a separate confirmed command.
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.
+10 -2
View File
@@ -298,6 +298,13 @@ veraltete Zustände 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.
Leere Stromkreisgruppen werden über `circuit-group.insert`,
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
eigener späterer Command.
`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
@@ -369,8 +376,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten sind integriert; Gruppen- und UI-Schritte
folgen in abgegrenzten Arbeitspaketen.
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
sind integriert; Gruppensortierung, befüllte Unterbäume und UI folgen in
abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+2 -1
View File
@@ -23,7 +23,8 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase C2a1: persistent insert/delete commands for mutable group
protection and auxiliary components.
- [x] Phase C2a2: component update and reorder commands.
- [ ] Phase C2b: group CRUD and reorder commands.
- [x] Phase C2b1: empty-group insert/update/delete commands.
- [ ] Phase C2b2: complete group reorder command.
- [ ] 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 and component management C2a are
complete. Group management C2b remains pending.
Status: In progress. New-board defaults C1, component management C2a and
empty-group CRUD C2b1 are complete. Complete group reordering C2b2 remains
pending.
- extend `distribution-board.insert` with fixed components and three groups
- implement component and group CRUD commands
@@ -635,6 +636,17 @@ Implemented in C2a2:
- stale expected state and cross-entity BMK collisions are rejected; Undo/Redo
restores the exact previous snapshot
Implemented in C2b1:
- `circuit-group.insert`, `circuit-group.update` and
`circuit-group.delete` persist complete group snapshots with stable UUIDs
- category, group number and prefix are validated as one consistent identity;
general updates change only the display name
- deletion requires an exact unchanged and empty group; populated destructive
deletion remains the separately confirmed Phase-D operation
- project/list ownership, stale state, Undo/Redo and late rollback are covered
at the shared transaction boundary
Acceptance:
- new boards contain the agreed fixed structure
@@ -0,0 +1,189 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupUpdateCommandType,
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupSnapshot,
type CircuitGroupStructureProjectCommand,
} from "../../domain/models/circuit-group-structure-project-command.model.js";
import type {
CircuitGroupStructureProjectCommandStore,
ExecuteCircuitGroupStructureCommandInput,
} from "../../domain/ports/circuit-group-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 { circuits } from "../schema/circuits.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class CircuitGroupStructureProjectCommandRepository
implements CircuitGroupStructureProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitGroupStructureCommandInput) {
return executeProjectCommandTransaction(
this.database,
input,
(tx) => this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: CircuitGroupStructureProjectCommand
): CircuitGroupStructureProjectCommand {
if (command.type === circuitGroupInsertCommandType) {
assertCircuitGroupInsertProjectCommand(command);
this.insert(database, projectId, command.payload.snapshot);
return createCircuitGroupDeleteProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupDeleteCommandType) {
assertCircuitGroupDeleteProjectCommand(command);
this.delete(database, projectId, command.payload.snapshot);
return createCircuitGroupInsertProjectCommand(
command.payload.snapshot
);
}
if (command.type === circuitGroupUpdateCommandType) {
assertCircuitGroupUpdateProjectCommand(command);
this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
return createCircuitGroupUpdateProjectCommand(
command.payload.target,
command.payload.expected
);
}
throw new Error("Unsupported circuit-group structure command.");
}
private insert(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const existing = database
.select({ id: circuitSections.id })
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get();
if (existing) {
throw new Error("Circuit-group id already exists.");
}
database.insert(circuitSections).values(snapshot).run();
}
private delete(
database: AppDatabase,
projectId: string,
snapshot: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, snapshot.circuitListId);
const current = this.getCurrent(database, snapshot.id);
if (!current || !sameRecord(snapshot, current)) {
throw new Error("Circuit group changed before deletion.");
}
const circuit = database
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, snapshot.id))
.get();
const component = database
.select({ id: distributionBoardComponents.id })
.from(distributionBoardComponents)
.where(eq(distributionBoardComponents.sectionId, snapshot.id))
.get();
if (circuit || component) {
throw new Error(
"Only empty circuit groups can be deleted by this command."
);
}
const deleted = database
.delete(circuitSections)
.where(
and(
eq(circuitSections.id, snapshot.id),
eq(circuitSections.circuitListId, snapshot.circuitListId)
)
)
.run();
if (deleted.changes !== 1) {
throw new Error("Circuit group could not be deleted.");
}
}
private update(
database: AppDatabase,
projectId: string,
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
) {
this.assertListOwnership(database, projectId, expected.circuitListId);
const current = this.getCurrent(database, expected.id);
if (!current || !sameRecord(expected, current)) {
throw new Error("Circuit group changed before update.");
}
const updated = database
.update(circuitSections)
.set({ displayName: target.displayName })
.where(
and(
eq(circuitSections.id, expected.id),
eq(circuitSections.circuitListId, expected.circuitListId)
)
)
.run();
if (updated.changes !== 1) {
throw new Error("Circuit group could not be updated.");
}
}
private assertListOwnership(
database: AppDatabase,
projectId: string,
circuitListId: string
) {
const list = database
.select({ projectId: circuitLists.projectId })
.from(circuitLists)
.where(eq(circuitLists.id, circuitListId))
.get();
if (!list || list.projectId !== projectId) {
throw new Error("Circuit group does not belong to project.");
}
}
private getCurrent(database: AppDatabase, id: string) {
return database
.select()
.from(circuitSections)
.where(eq(circuitSections.id, id))
.get();
}
}
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,192 @@
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import { formatCircuitGroupPrefix } from "../services/circuit-group-numbering.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitGroupInsertCommandType = "circuit-group.insert" as const;
export const circuitGroupDeleteCommandType = "circuit-group.delete" as const;
export const circuitGroupUpdateCommandType = "circuit-group.update" as const;
export const circuitGroupStructureCommandSchemaVersion = 1 as const;
export interface CircuitGroupSnapshot {
id: string;
circuitListId: string;
key: string;
displayName: string;
prefix: string;
sortOrder: number;
category: CircuitGroupCategory;
groupNumber: number;
}
interface SnapshotPayload {
snapshot: CircuitGroupSnapshot;
}
interface UpdatePayload {
expected: CircuitGroupSnapshot;
target: CircuitGroupSnapshot;
}
export interface CircuitGroupInsertProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupInsertCommandType;
}
export interface CircuitGroupDeleteProjectCommand
extends SerializedProjectCommand<SnapshotPayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupDeleteCommandType;
}
export interface CircuitGroupUpdateProjectCommand
extends SerializedProjectCommand<UpdatePayload> {
schemaVersion: typeof circuitGroupStructureCommandSchemaVersion;
type: typeof circuitGroupUpdateCommandType;
}
export type CircuitGroupStructureProjectCommand =
| CircuitGroupInsertProjectCommand
| CircuitGroupDeleteProjectCommand
| CircuitGroupUpdateProjectCommand;
export function createCircuitGroupInsertProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupInsertProjectCommand {
const command: CircuitGroupInsertProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupInsertCommandType,
payload: { snapshot },
};
assertCircuitGroupInsertProjectCommand(command);
return command;
}
export function createCircuitGroupDeleteProjectCommand(
snapshot: CircuitGroupSnapshot
): CircuitGroupDeleteProjectCommand {
const command: CircuitGroupDeleteProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupDeleteCommandType,
payload: { snapshot },
};
assertCircuitGroupDeleteProjectCommand(command);
return command;
}
export function createCircuitGroupUpdateProjectCommand(
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
): CircuitGroupUpdateProjectCommand {
const command: CircuitGroupUpdateProjectCommand = {
schemaVersion: circuitGroupStructureCommandSchemaVersion,
type: circuitGroupUpdateCommandType,
payload: { expected, target },
};
assertCircuitGroupUpdateProjectCommand(command);
return command;
}
export function assertCircuitGroupInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupInsertProjectCommand {
assertSnapshotCommand(command, circuitGroupInsertCommandType);
}
export function assertCircuitGroupDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupDeleteProjectCommand {
assertSnapshotCommand(command, circuitGroupDeleteCommandType);
}
export function assertCircuitGroupUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitGroupUpdateProjectCommand {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== circuitGroupUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported circuit-group update command.");
}
assertCircuitGroupSnapshot(command.payload.expected);
assertCircuitGroupSnapshot(command.payload.target);
for (const field of [
"id",
"circuitListId",
"key",
"prefix",
"sortOrder",
"category",
"groupNumber",
] as const) {
if (command.payload.expected[field] !== command.payload.target[field]) {
throw new Error(
"Circuit-group updates can only change the display name."
);
}
}
}
function assertSnapshotCommand(
command: SerializedProjectCommand<unknown>,
type:
| typeof circuitGroupInsertCommandType
| typeof circuitGroupDeleteCommandType
) {
if (
command.schemaVersion !== circuitGroupStructureCommandSchemaVersion ||
command.type !== type ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported circuit-group structure command.");
}
assertCircuitGroupSnapshot(command.payload.snapshot);
}
export function assertCircuitGroupSnapshot(
value: unknown
): asserts value is CircuitGroupSnapshot {
if (!isPlainObject(value) || Object.keys(value).length !== 8) {
throw new Error("Circuit-group snapshot is invalid.");
}
for (const field of [
"id",
"circuitListId",
"key",
"displayName",
"prefix",
] as const) {
if (typeof value[field] !== "string" || !value[field].trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
if (
!circuitGroupCategories.includes(
value.category as CircuitGroupCategory
) ||
!Number.isInteger(value.groupNumber) ||
(value.groupNumber as number) < 1 ||
!Number.isFinite(value.sortOrder)
) {
throw new Error("Circuit-group category, number or order is invalid.");
}
if (
value.prefix !==
formatCircuitGroupPrefix(
value.category as CircuitGroupCategory,
value.groupNumber as number
)
) {
throw new Error("Circuit-group prefix does not match its category and number.");
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,22 @@
import type { CircuitGroupStructureProjectCommand } from "../models/circuit-group-structure-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitGroupStructureCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitGroupStructureProjectCommand;
}
export interface CircuitGroupStructureProjectCommandStore {
execute(input: ExecuteCircuitGroupStructureCommandInput): {
revision: AppendedProjectRevision;
inverse: CircuitGroupStructureProjectCommand;
};
}
@@ -36,6 +36,13 @@ function getGroupStem(
return `-${circuitGroupCategoryNumbers[category]}${functionLetter}${groupNumber}`;
}
export function formatCircuitGroupPrefix(
category: CircuitGroupCategory,
groupNumber: number
) {
return `${getGroupStem(category, "F", groupNumber)}.`;
}
export function formatGroupUpstreamProtectionIdentifier(
category: CircuitGroupCategory,
groupNumber: number
@@ -98,6 +98,7 @@ import type { CircuitProjectCommandStore } from "../ports/circuit-project-comman
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";
import type { CircuitGroupStructureProjectCommandStore } from "../ports/circuit-group-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 {
@@ -110,6 +111,14 @@ import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import {
assertCircuitGroupDeleteProjectCommand,
assertCircuitGroupInsertProjectCommand,
assertCircuitGroupUpdateProjectCommand,
circuitGroupDeleteCommandType,
circuitGroupInsertCommandType,
circuitGroupUpdateCommandType,
} from "../models/circuit-group-structure-project-command.model.js";
import type { ProjectLocationStructureProjectCommandStore } from "../ports/project-location-structure-project-command.store.js";
import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-project-command.store.js";
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
@@ -145,6 +154,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectSettingsStore: ProjectSettingsProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly distributionBoardComponentStructureStore: DistributionBoardComponentStructureProjectCommandStore,
private readonly circuitGroupStructureStore: CircuitGroupStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -333,6 +343,27 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitGroupInsertCommandType: {
assertCircuitGroupInsertProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupDeleteCommandType: {
assertCircuitGroupDeleteProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitGroupUpdateCommandType: {
assertCircuitGroupUpdateProjectCommand(input.command);
return this.circuitGroupStructureStore.execute({
...input,
command: input.command,
}).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@@ -36,6 +36,9 @@ const commandTypeLabels: Record<string, string> = {
"distribution-board-component.insert": "Verteilergerät angelegt",
"distribution-board-component.delete": "Verteilergerät entfernt",
"distribution-board-component.update": "Verteilergerät bearbeitet",
"circuit-group.insert": "Stromkreisgruppe angelegt",
"circuit-group.delete": "Stromkreisgruppe entfernt",
"circuit-group.update": "Stromkreisgruppe bearbeitet",
"project-floor.insert": "Geschoss angelegt",
"project-floor.delete": "Geschoss entfernt",
"project-room.insert": "Raum angelegt",
@@ -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 { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-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";
@@ -26,6 +27,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const circuitGroupStructureProjectCommandStore =
new CircuitGroupStructureProjectCommandRepository(db);
export const distributionBoardStructureProjectCommandStore =
new DistributionBoardStructureProjectCommandRepository(db);
export const distributionBoardComponentStructureProjectCommandStore =
@@ -63,5 +66,6 @@ export const projectCommandService = new ProjectCommandService(
projectSettingsProjectCommandStore,
projectStateRestoreCommandStore,
distributionBoardComponentStructureProjectCommandStore,
circuitGroupStructureProjectCommandStore,
projectHistoryStore
);
+1
View File
@@ -8,6 +8,7 @@ import {
getNextGroupedCircuitIdentifier,
parseGroupedEquipmentIdentifier,
} from "../src/domain/services/circuit-group-numbering.js";
import "./circuit-group-structure-project-command.repository.test.js";
describe("circuit group numbering", () => {
it("formats the agreed identifiers including the leading hyphen", () => {
@@ -0,0 +1,296 @@
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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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 { circuits } from "../src/db/schema/circuits.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import {
createCircuitGroupDeleteProjectCommand,
createCircuitGroupInsertProjectCommand,
createCircuitGroupUpdateProjectCommand,
type CircuitGroupSnapshot,
} from "../src/domain/models/circuit-group-structure-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");
new DistributionBoardFixtureRepository(
context.db
).createWithCircuitListAndDefaultSections("project-2", "UV-02");
return context;
}
function getProjectList(context: DatabaseContext, projectId: string) {
const list = context.db
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, projectId))
.get();
assert.ok(list);
return list;
}
function groupSnapshot(circuitListId: string): CircuitGroupSnapshot {
return {
id: "group-lighting-2",
circuitListId,
key: "lighting_2",
displayName: "Beleuchtung 2",
prefix: "-1F2.",
sortOrder: 40,
category: "lighting",
groupNumber: 2,
};
}
describe("circuit-group structure project command", () => {
it("inserts, renames and restores a group through persistent history", () => {
const context = createTestDatabase();
try {
const expected = groupSnapshot(
getProjectList(context, "project-1").id
);
const target = {
...expected,
displayName: "Beleuchtung Besprechungsräume",
};
const repository =
new CircuitGroupStructureProjectCommandRepository(context.db);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupInsertProjectCommand(expected),
});
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitGroupUpdateProjectCommand(
expected,
target
),
});
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, expected.id))
.get()?.displayName,
target.displayName
);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: updated.inverse,
});
assert.deepEqual(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, expected.id))
.get(),
expected
);
} finally {
context.close();
}
});
it("deletes only an exact empty group and restores the same UUID", () => {
const context = createTestDatabase();
try {
const snapshot = groupSnapshot(
getProjectList(context, "project-1").id
);
const repository =
new CircuitGroupStructureProjectCommandRepository(context.db);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupInsertProjectCommand(snapshot),
});
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitGroupDeleteProjectCommand(snapshot),
});
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get(),
undefined
);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo")?.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get(),
snapshot
);
} finally {
context.close();
}
});
it("rejects foreign lists, invalid prefixes, stale updates and populated deletes", () => {
const context = createTestDatabase();
try {
const snapshot = groupSnapshot(
getProjectList(context, "project-1").id
);
const repository =
new CircuitGroupStructureProjectCommandRepository(context.db);
assert.throws(
() =>
createCircuitGroupInsertProjectCommand({
...snapshot,
prefix: "-1F9.",
}),
/prefix does not match/
);
assert.throws(
() =>
repository.execute({
projectId: "project-2",
expectedRevision: 0,
source: "user",
command: createCircuitGroupInsertProjectCommand(snapshot),
}),
/does not belong/
);
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupInsertProjectCommand(snapshot),
});
context.db
.insert(circuits)
.values({
id: "group-circuit",
circuitListId: snapshot.circuitListId,
sectionId: snapshot.id,
equipmentIdentifier: "-1F2.1",
displayName: "Beleuchtung",
sortOrder: 10,
})
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitGroupDeleteProjectCommand(snapshot),
}),
/Only empty circuit groups/
);
context.db
.update(circuitSections)
.set({ displayName: "Direkt geändert" })
.where(eq(circuitSections.id, snapshot.id))
.run();
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitGroupUpdateProjectCommand(snapshot, {
...snapshot,
displayName: "Ziel",
}),
}),
/changed before update/
);
assert.equal(
context.db.select().from(projectRevisions).all().length,
1
);
} finally {
context.close();
}
});
it("rolls back group insertion when history persistence fails", () => {
const context = createTestDatabase();
try {
const snapshot = groupSnapshot(
getProjectList(context, "project-1").id
);
context.sqlite.exec(`
CREATE TRIGGER fail_group_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced group history failure');
END;
`);
assert.throws(
() =>
new CircuitGroupStructureProjectCommandRepository(
context.db
).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitGroupInsertProjectCommand(snapshot),
}),
/forced group history failure/
);
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, snapshot.id))
.get(),
undefined
);
} finally {
context.close();
}
});
});
+68
View File
@@ -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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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";
@@ -47,6 +48,10 @@ import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import {
createCircuitGroupInsertProjectCommand,
createCircuitGroupUpdateProjectCommand,
} from "../src/domain/models/circuit-group-structure-project-command.model.js";
import {
createDistributionBoardInsertProjectCommand,
createDistributionBoardStructureSnapshot,
@@ -131,6 +136,7 @@ function createService(context: DatabaseContext) {
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
),
new CircuitGroupStructureProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
@@ -1207,6 +1213,68 @@ describe("project command service", () => {
}
});
it("dispatches circuit-group structure commands", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const circuitListId = context.db
.select()
.from(circuitSections)
.get()!.circuitListId;
const expected = {
id: "group-service",
circuitListId,
key: "lighting_2",
displayName: "Beleuchtung 2",
prefix: "-1F2.",
sortOrder: 40,
category: "lighting" as const,
groupNumber: 2,
};
const target = {
...expected,
displayName: "Beleuchtung Nebenräume",
};
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitGroupInsertProjectCommand(expected),
});
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createCircuitGroupUpdateProjectCommand(
expected,
target
),
});
assert.equal(updated.history.currentRevision, 2);
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, expected.id))
.get()?.displayName,
target.displayName
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 2,
});
assert.equal(
context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.id, expected.id))
.get()?.displayName,
expected.displayName
);
} finally {
context.close();
}
});
it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase();
try {
@@ -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 { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-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";
@@ -195,6 +196,7 @@ function createService(context: DatabaseContext) {
new DistributionBoardComponentStructureProjectCommandRepository(
context.db
),
new CircuitGroupStructureProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
+18
View File
@@ -139,6 +139,24 @@ describe("project version history presentation", () => {
),
"Verteilergerät bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(8, { commandType: "circuit-group.insert" })
),
"Stromkreisgruppe angelegt"
);
assert.equal(
getProjectRevisionDescription(
revision(9, { commandType: "circuit-group.delete" })
),
"Stromkreisgruppe entfernt"
);
assert.equal(
getProjectRevisionDescription(
revision(10, { commandType: "circuit-group.update" })
),
"Stromkreisgruppe bearbeitet"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),