leistungsbilanz-ts/tests/circuit-structure-project-command.repository.test.ts

665 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.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 { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import type { CircuitDeviceRowSnapshot } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
type CircuitSnapshot,
} from "../src/domain/models/circuit-structure-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
circuitListId: string;
sectionId: string;
foreignCircuitListId: string;
foreignSectionId: string;
}
function createTestDatabase(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardFixtureRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(section);
assert.ok(foreignSection);
context.db
.insert(projectDevices)
.values([
{
id: "device-1",
projectId: "project-1",
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
id: "device-foreign",
projectId: "project-2",
name: "Fremdgerät",
displayName: "Fremdgerät",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(rooms)
.values([
{
id: "room-1",
projectId: "project-1",
roomNumber: "001",
roomName: "Büro",
},
{
id: "room-foreign",
projectId: "project-2",
roomNumber: "999",
roomName: "Fremdraum",
},
])
.run();
context.db
.insert(circuits)
.values({
id: "circuit-existing",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
cableType: "NYM-J",
cableCrossSection: "3x1,5",
cableLength: 18.5,
rcdAssignment: "FI-1",
terminalDesignation: "X1",
voltage: 230,
controlRequirement: "DALI",
status: "planned",
isReserve: 0,
remark: "Vollständig",
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-existing-1",
circuitId: "circuit-existing",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "Leuchte A",
displayName: "Leuchte A lokal",
phaseType: "single_phase",
category: "lighting",
roomId: "room-1",
roomNumberSnapshot: "001",
roomNameSnapshot: "Büro",
quantity: 2,
powerPerUnit: 0.1,
simultaneityFactor: 0.8,
cosPhi: 0.9,
overriddenFields: "[\"displayName\"]",
},
{
id: "row-existing-2",
circuitId: "circuit-existing",
sortOrder: 20,
name: "Leuchte B",
displayName: "Leuchte B",
quantity: 1,
powerPerUnit: 0.2,
simultaneityFactor: 1,
},
])
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-existing",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
return {
context,
circuitListId: board.id,
sectionId: section.id,
foreignCircuitListId: foreignBoard.id,
foreignSectionId: foreignSection.id,
};
}
function createRow(
circuitId: string,
id: string,
sortOrder: number,
overrides: Partial<CircuitDeviceRowSnapshot> = {}
): CircuitDeviceRowSnapshot {
return {
id,
circuitId,
linkedProjectDeviceId: null,
sortOrder,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: null,
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
...overrides,
};
}
function createCircuitSnapshot(
fixture: TestFixture,
overrides: Partial<CircuitSnapshot> = {}
): CircuitSnapshot {
const id = overrides.id ?? "circuit-new";
const deviceRows =
overrides.deviceRows ??
[
createRow(id, "row-new-1", 10, {
linkedProjectDeviceId: "device-1",
roomId: "room-1",
}),
createRow(id, "row-new-2", 20),
];
return {
id,
circuitListId: fixture.circuitListId,
sectionId: fixture.sectionId,
equipmentIdentifier: "-1F2",
displayName: "Neuer Stromkreis",
sortOrder: 20,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: deviceRows.length === 0,
remark: null,
deviceRows,
...overrides,
};
}
function getCircuit(
context: DatabaseContext,
circuitId: string
) {
return context.db
.select()
.from(circuits)
.where(eq(circuits.id, circuitId))
.get();
}
function getRows(context: DatabaseContext, circuitId: string) {
return context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.orderBy(
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
}
describe("circuit structure project-command repository", () => {
it("inserts a multi-device circuit and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const command = createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(getCircuit(fixture.context, "circuit-new")?.isReserve, 0);
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.equal(getRows(fixture.context, "circuit-new").length, 0);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command,
});
assert.deepEqual(
getRows(fixture.context, "circuit-new").map((row) => row.id),
["row-new-1", "row-new-2"]
);
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: inserted.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("inserts and removes an empty reserve circuit", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const inserted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "circuit-reserve",
equipmentIdentifier: "-1F3",
deviceRows: [],
isReserve: true,
})
),
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve")?.isReserve,
1
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
getCircuit(fixture.context, "circuit-reserve"),
undefined
);
} finally {
fixture.context.close();
}
});
it("deletes and restores a complete multi-device circuit block", () => {
const fixture = createTestDatabase();
try {
const beforeCircuit = getCircuit(
fixture.context,
"circuit-existing"
);
const beforeRows = getRows(fixture.context, "circuit-existing");
const beforeProtection = fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get();
assert.ok(beforeCircuit);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const deleted = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
});
assert.equal(
getCircuit(fixture.context, "circuit-existing"),
undefined
);
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
assert.equal(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
undefined
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: deleted.revision.changeSetId,
command: deleted.inverse,
});
assert.deepEqual(
getCircuit(fixture.context, "circuit-existing"),
beforeCircuit
);
assert.deepEqual(
getRows(fixture.context, "circuit-existing"),
beforeRows
);
assert.deepEqual(
fixture.context.db
.select()
.from(circuitProtectionDevices)
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
.get(),
beforeProtection
);
} finally {
fixture.context.close();
}
});
it("blocks deleting a circuit that feeds a distribution board", () => {
const fixture = createTestDatabase();
try {
const downstream = new DistributionBoardFixtureRepository(
fixture.context.db
).createWithCircuitListAndDefaultSections("project-1", "UV Unterverteiler");
fixture.context.db
.update(distributionBoards)
.set({
boardNumber: "UV-02",
feedingDistributionBoardId: fixture.circuitListId,
feedingCircuitId: "circuit-existing",
})
.where(eq(distributionBoards.id, downstream.id))
.run();
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/Speist UV-02 – erst Einspeisung ändern\./
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back insert and delete for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_circuit_structure_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced circuit structure history failure');
END;
`);
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture)
),
}),
/forced circuit structure history failure/
);
assert.equal(getCircuit(fixture.context, "circuit-new"), undefined);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/forced circuit structure history failure/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back deletion for a stale expected revision", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitDeleteProjectCommand(
"circuit-existing",
fixture.circuitListId
),
}),
/at revision 0, expected 1/
);
assert.ok(getCircuit(fixture.context, "circuit-existing"));
assert.equal(getRows(fixture.context, "circuit-existing").length, 2);
} finally {
fixture.context.close();
}
});
it("rejects invalid ownership, identifiers, references and row ids", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
const invalidSnapshots = [
createCircuitSnapshot(fixture, {
id: "foreign-list",
circuitListId: fixture.foreignCircuitListId,
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "foreign-section",
sectionId: fixture.foreignSectionId,
}),
createCircuitSnapshot(fixture, {
id: "duplicate-bmk",
equipmentIdentifier: "-1F1",
}),
createCircuitSnapshot(fixture, {
id: "duplicate-row",
deviceRows: [
createRow("duplicate-row", "row-existing-1", 10),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-device",
deviceRows: [
createRow("foreign-device", "row-foreign-device", 10, {
linkedProjectDeviceId: "device-foreign",
}),
],
}),
createCircuitSnapshot(fixture, {
id: "foreign-room",
deviceRows: [
createRow("foreign-room", "row-foreign-room", 10, {
roomId: "room-foreign",
}),
],
}),
];
for (const snapshot of invalidSnapshots) {
assert.throws(() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(snapshot),
})
);
}
assert.equal(
fixture.context.db.select().from(projectRevisions).all().length,
0
);
} finally {
fixture.context.close();
}
});
it("rejects a BMK that differs from an existing one only by German umlaut casing", () => {
const fixture = createTestDatabase();
try {
const store = new CircuitStructureProjectCommandRepository(
fixture.context.db
);
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-original",
equipmentIdentifier: "-1F9Ä",
deviceRows: [],
})
),
});
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createCircuitInsertProjectCommand(
createCircuitSnapshot(fixture, {
id: "umlaut-duplicate",
equipmentIdentifier: "-1f9ä",
deviceRows: [],
})
),
}),
/Duplicate equipmentIdentifier in circuit list\./
);
} finally {
fixture.context.close();
}
});
});