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
@@ -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"),