Files
leistungsbilanz-ts/tests/project-command.model.test.ts
T

744 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectCommand,
serializeProjectCommand,
type SerializedProjectCommand,
} from "../src/domain/models/project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
createCircuitUpdateProjectCommand,
} from "../src/domain/models/circuit-project-command.model.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
createCircuitDeviceRowUpdateProjectCommand,
} from "../src/domain/models/circuit-device-row-project-command.model.js";
import {
assertCircuitDeviceRowInsertProjectCommand,
createCircuitDeviceRowDeleteProjectCommand,
createCircuitDeviceRowInsertProjectCommand,
} from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import {
assertCircuitDeviceRowMoveProjectCommand,
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand,
createCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveWithNewCircuitProjectCommand,
} from "../src/domain/models/circuit-device-row-move-project-command.model.js";
import {
assertCircuitInsertProjectCommand,
createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand,
} from "../src/domain/models/circuit-structure-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand,
} from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
} from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../src/domain/models/project-device-row-sync-project-command.model.js";
describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => {
const command: SerializedProjectCommand = {
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
changes: {
displayName: "Werkstatt",
protectionRatedCurrent: 16,
isReserve: false,
remark: null,
},
},
};
assert.deepEqual(
deserializeProjectCommand(serializeProjectCommand(command)),
command
);
});
it("rejects values that JSON would lose or silently change", () => {
const invalidPayloads: unknown[] = [
{ value: undefined },
{ value: Number.NaN },
{ value: Number.POSITIVE_INFINITY },
{ value: new Date() },
];
for (const payload of invalidPayloads) {
assert.throws(() =>
serializeProjectCommand({
schemaVersion: 1,
type: "test.invalid",
payload: payload as never,
})
);
}
});
it("rejects cyclic payloads before persistence", () => {
const payload: Record<string, unknown> = {};
payload.self = payload;
assert.throws(
() =>
serializeProjectCommand({
schemaVersion: 1,
type: "test.cyclic",
payload: payload as never,
}),
/must not contain cycles/
);
});
it("rejects malformed serialized envelopes", () => {
assert.throws(
() =>
deserializeProjectCommand(
JSON.stringify({ schemaVersion: 0, type: "", payload: null })
),
/schema version/
);
});
});
describe("circuit update project commands", () => {
it("creates a typed command with explicit null clearing semantics", () => {
const command = createCircuitUpdateProjectCommand("circuit-1", {
displayName: null,
cableLength: 12.5,
isReserve: false,
});
assert.doesNotThrow(() => assertCircuitUpdateProjectCommand(command));
assert.deepEqual(command.payload.changes, [
{ field: "displayName", value: null },
{ field: "cableLength", value: 12.5 },
{ field: "isReserve", value: false },
]);
});
it("rejects empty, duplicate and invalid field changes", () => {
assert.throws(
() => createCircuitUpdateProjectCommand("circuit-1", {}),
/at least one change/
);
assert.throws(
() =>
assertCircuitUpdateProjectCommand({
schemaVersion: 1,
type: "circuit.update",
payload: {
circuitId: "circuit-1",
changes: [
{ field: "displayName", value: "A" },
{ field: "displayName", value: "B" },
],
},
}),
/duplicate field/
);
assert.throws(
() =>
createCircuitUpdateProjectCommand("circuit-1", {
voltage: 0,
}),
/outside its allowed range/
);
});
});
describe("circuit device-row update project commands", () => {
it("creates typed nullable device-row changes", () => {
const command = createCircuitDeviceRowUpdateProjectCommand("row-1", {
roomId: null,
quantity: 2,
cosPhi: 0.9,
});
assert.doesNotThrow(() =>
assertCircuitDeviceRowUpdateProjectCommand(command)
);
assert.deepEqual(command.payload.changes, [
{ field: "roomId", value: null },
{ field: "quantity", value: 2 },
{ field: "cosPhi", value: 0.9 },
]);
});
it("rejects invalid device-row values", () => {
assert.throws(
() => createCircuitDeviceRowUpdateProjectCommand("row-1", {}),
/at least one change/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
displayName: "",
}),
/non-empty string/
);
assert.throws(
() =>
createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: -1,
}),
/non-negative/
);
});
});
describe("project-device row sync project commands", () => {
const linkedSnapshot: ProjectDeviceSyncRowSnapshot = {
linkedProjectDeviceId: "project-device-1",
name: "Leuchte",
displayName: "Lokaler Name",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: '["displayName"]',
};
it("captures complete expected and target snapshots for synchronization", () => {
const command = createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
displayName: "Projektgerät",
overriddenFields: null,
},
},
]
);
assert.doesNotThrow(() =>
assertProjectDeviceRowSyncProjectCommand(command)
);
assert.equal(
command.payload.rows[0]?.target.displayName,
"Projektgerät"
);
});
it("permits link-only disconnects and rejects invalid sync transitions", () => {
assert.doesNotThrow(() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
},
},
]
)
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"disconnect",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: {
...linkedSnapshot,
linkedProjectDeviceId: null,
quantity: 2,
},
},
]
),
/must not change local row values/
);
assert.throws(
() =>
createProjectDeviceRowSyncProjectCommand(
"project-device-1",
"synchronize",
[
{
rowId: "row-1",
expected: linkedSnapshot,
target: linkedSnapshot,
},
]
),
/no-op row/
);
});
});
describe("circuit device-row structure project commands", () => {
const row = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: 0.9,
remark: null,
overriddenFields: null,
};
it("captures complete insert and delete identities", () => {
const insert = createCircuitDeviceRowInsertProjectCommand(row);
const remove = createCircuitDeviceRowDeleteProjectCommand(
row.id,
row.circuitId
);
assert.deepEqual(insert.payload.row, row);
assert.deepEqual(remove.payload, {
rowId: "row-1",
expectedCircuitId: "circuit-1",
});
});
it("rejects incomplete snapshots and invalid values", () => {
assert.throws(
() =>
assertCircuitDeviceRowInsertProjectCommand({
schemaVersion: 1,
type: "circuit-device-row.insert",
payload: {
row: {
...row,
linkedProjectDeviceId: undefined,
},
},
}),
/linkedProjectDeviceId/
);
assert.throws(
() =>
createCircuitDeviceRowInsertProjectCommand({
...row,
quantity: -1,
}),
/must not be negative/
);
assert.throws(
() => createCircuitDeviceRowDeleteProjectCommand("", "circuit-1"),
/rowId/
);
});
});
describe("circuit device-row move project commands", () => {
const targetCircuit = {
id: "circuit-new",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F3",
displayName: "Neuer Stromkreis",
sortOrder: 30,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
};
it("captures deterministic source and target positions", () => {
const command = createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-2",
targetSortOrder: 30,
},
{
rowId: "row-2",
expectedCircuitId: "circuit-1",
expectedSortOrder: 20,
targetCircuitId: "circuit-2",
targetSortOrder: 40,
},
]);
assert.equal(command.payload.moves.length, 2);
assert.doesNotThrow(() =>
assertCircuitDeviceRowMoveProjectCommand(command)
);
});
it("rejects empty, duplicate and no-op moves", () => {
assert.throws(
() => createCircuitDeviceRowMoveProjectCommand([]),
/at least one move/
);
assert.throws(
() =>
createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-2",
targetSortOrder: 20,
},
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 30,
targetCircuitId: "circuit-2",
targetSortOrder: 40,
},
]),
/duplicate row ids/
);
assert.throws(
() =>
createCircuitDeviceRowMoveProjectCommand([
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "circuit-1",
targetSortOrder: 10,
},
]),
/no-op/
);
});
it("captures creation and deletion of a deterministic move target", () => {
const create =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
]
);
const remove =
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"delete",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: targetCircuit.id,
expectedSortOrder: 10,
targetCircuitId: "circuit-1",
targetSortOrder: 10,
},
]
);
assert.doesNotThrow(() =>
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand(
create
)
);
assert.equal(remove.payload.targetCircuitAction, "delete");
});
it("rejects inconsistent move targets and non-empty snapshots", () => {
assert.throws(
() =>
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
"create",
targetCircuit,
[
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: "other-circuit",
targetSortOrder: 10,
},
]
),
/move rows into the new circuit/
);
assert.throws(
() =>
assertCircuitDeviceRowMoveWithNewCircuitProjectCommand({
schemaVersion: 1,
type: "circuit-device-row.move-with-new-circuit",
payload: {
targetCircuitAction: "create",
targetCircuit: {
...targetCircuit,
isReserve: false,
},
moves: [
{
rowId: "row-1",
expectedCircuitId: "circuit-1",
expectedSortOrder: 10,
targetCircuitId: targetCircuit.id,
targetSortOrder: 10,
},
],
},
}),
/reserve state/
);
});
});
describe("circuit structure project commands", () => {
const row = {
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: null,
legacyConsumerId: null,
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
phaseType: "single_phase",
connectionKind: null,
costGroup: null,
category: "lighting",
level: null,
roomId: null,
roomNumberSnapshot: null,
roomNameSnapshot: null,
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
};
const circuit = {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: "DALI",
status: null,
isReserve: false,
remark: null,
deviceRows: [row],
};
it("captures complete circuit blocks and delete identities", () => {
const insert = createCircuitInsertProjectCommand(circuit);
const remove = createCircuitDeleteProjectCommand(
circuit.id,
circuit.circuitListId
);
assert.deepEqual(insert.payload.circuit, circuit);
assert.deepEqual(remove.payload, {
circuitId: "circuit-1",
expectedCircuitListId: "list-1",
});
});
it("rejects inconsistent reserve state, row ownership and duplicate ids", () => {
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
isReserve: true,
}),
/reserve state/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [{ ...row, circuitId: "other-circuit" }],
}),
/different circuit/
);
assert.throws(
() =>
createCircuitInsertProjectCommand({
...circuit,
deviceRows: [row, { ...row }],
}),
/duplicate row ids/
);
assert.throws(
() =>
assertCircuitInsertProjectCommand({
schemaVersion: 1,
type: "circuit.insert",
payload: {
circuit: { ...circuit, equipmentIdentifier: "" },
},
}),
/equipmentIdentifier/
);
});
});
describe("circuit section reorder project commands", () => {
it("captures every expected and target sort position", () => {
const command = createCircuitSectionReorderProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionReorderProjectCommand(command)
);
});
it("rejects empty, duplicate and complete no-op assignments", () => {
assert.throws(
() => createCircuitSectionReorderProjectCommand("", []),
/sectionId/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-1",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]),
/duplicate circuit ids/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 10,
},
]),
/change at least one position/
);
});
});
describe("circuit section renumber project commands", () => {
it("captures every expected and target equipment identifier", () => {
const command = createCircuitSectionRenumberProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionRenumberProjectCommand(command)
);
});
it("rejects duplicate and complete no-op assignments", () => {
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F2",
},
]),
/duplicate target identifiers/
);
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F1",
},
]),
/change at least one identifier/
);
});
});