leistungsbilanz-ts/tests/circuit-group-numbering.test.ts
Julian Appel 7222398628 Typecheck tests against current types
tsx runs tests without type checking and tsconfig.json covers only src,
so 71 type errors had accumulated unnoticed. tsconfig.tests.json now
checks every test and helper, and npm run typecheck includes it.

Fixtures gain the fields the DTOs have grown since they were written
(manualQuantity, circuit apparent power/cos phi, downstream board loads,
section totals, building, cover and board info fields). Repository
lookups that return "row ?? null" now declare the null in their type,
which their callers already handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:17:45 +01:00

759 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
formatGroupedCircuitIdentifier,
formatGroupRcdIdentifier,
formatGroupUpstreamProtectionIdentifier,
getNextCircuitGroupNumber,
getNextGroupedCircuitIdentifier,
parseGroupedEquipmentIdentifier,
} from "../src/domain/services/circuit-group-numbering.js";
import "./circuit-group-structure-project-command.repository.test.js";
import { createCircuitGroupRenumberPlan } from "../src/domain/services/circuit-group-renumbering.js";
import "./circuit-group-renumber-project-command.repository.test.js";
import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group-move-planning.js";
import "./circuit-group-move-project-command.repository.test.js";
import "./circuit-group-subtree-snapshot.test.js";
import "./circuit-group-subtree-project-command.repository.test.js";
import {
buildCircuitGroupMovePlan,
buildCircuitGroupRenumberPlan,
buildCircuitGroupReorderAssignments,
buildCircuitGroupSubtreeSnapshot,
buildNewCircuitGroupSnapshot,
canMoveCircuitToGroup,
canRenumberCircuitGroups,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
summarizeCircuitGroupSubtree,
} from "../src/frontend/utils/circuit-group-editing.js";
describe("circuit group numbering", () => {
it("builds the next group without filling gaps or renumbering existing groups", () => {
const sections = [
groupSection("lighting-1", "lighting", 1, 10),
groupSection("single-1", "single_phase", 1, 20),
groupSection("lighting-3", "lighting", 3, 15),
];
const snapshot = buildNewCircuitGroupSnapshot({
id: "lighting-4",
circuitListId: "list-1",
category: "lighting",
sections,
});
assert.deepEqual(snapshot, {
id: "lighting-4",
circuitListId: "list-1",
key: "lighting_4",
displayName: "Beleuchtung 4",
prefix: "-1F4.",
sortOrder: 17.5,
category: "lighting",
groupNumber: 4,
});
assert.equal(
renameCircuitGroupSnapshot(snapshot, " Beleuchtung Nord ").displayName,
"Beleuchtung Nord"
);
assert.equal(canDeleteCircuitGroup(sections[0]), true);
assert.equal(
canDeleteCircuitGroup({
...sections[0],
components: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: sections[0].id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
],
}),
false
);
});
it("reorders complete group lists only between category peers", () => {
const sections = [
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 15),
groupSection("single-1", "single_phase", 1, 20),
];
assert.deepEqual(
buildCircuitGroupReorderAssignments(
sections,
"lighting-1",
1
),
[
{
groupId: "lighting-1",
expectedSortOrder: 10,
targetSortOrder: 15,
},
{
groupId: "lighting-2",
expectedSortOrder: 15,
targetSortOrder: 10,
},
{
groupId: "single-1",
expectedSortOrder: 20,
targetSortOrder: 20,
},
]
);
assert.equal(
buildCircuitGroupReorderAssignments(
sections,
"lighting-1",
-1
),
null
);
assert.equal(
buildCircuitGroupReorderAssignments(
sections,
"single-1",
1
),
null
);
});
it("renumbers a complete category by group order and preserves circuit suffixes", () => {
const sections = [
{
...groupSection("lighting-3", "lighting", 3, 10),
prefix: "-1F3.",
circuits: [
{
id: "circuit-7",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1F3.7",
sortOrder: 10,
isReserve: false,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [],
downstreamBoardLoads: [],
},
],
components: [
{
id: "rcd-3",
circuitListId: "list-1",
sectionId: "lighting-3",
equipmentIdentifier: "-1Q3.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
},
{
...groupSection("lighting-1", "lighting", 1, 20),
prefix: "-1F1.",
},
groupSection("single-1", "single_phase", 1, 30),
];
assert.deepEqual(buildCircuitGroupRenumberPlan(sections, "lighting"), {
groups: [
{
groupId: "lighting-3",
category: "lighting",
expectedGroupNumber: 3,
targetGroupNumber: 1,
expectedPrefix: "-1F3.",
targetPrefix: "-1F1.",
circuits: [
{
circuitId: "circuit-7",
expectedEquipmentIdentifier: "-1F3.7",
targetEquipmentIdentifier: "-1F1.7",
},
],
components: [
{
componentId: "rcd-3",
expectedEquipmentIdentifier: "-1Q3.0",
targetEquipmentIdentifier: "-1Q1.0",
},
],
},
{
groupId: "lighting-1",
category: "lighting",
expectedGroupNumber: 1,
targetGroupNumber: 2,
expectedPrefix: "-1F1.",
targetPrefix: "-1F2.",
circuits: [],
components: [],
},
],
});
assert.equal(
buildCircuitGroupRenumberPlan(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
null
);
assert.equal(
canRenumberCircuitGroups(sections, "lighting"),
true
);
assert.equal(
canRenumberCircuitGroups(
[
groupSection("lighting-1", "lighting", 1, 10),
groupSection("lighting-2", "lighting", 2, 20),
],
"lighting"
),
false
);
});
it("plans a same-category circuit drop with the next target BMK", () => {
const source = {
...groupSection("lighting-1", "lighting", 1, 10),
circuits: [
{
id: "circuit-source",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.5",
sortOrder: 10,
isReserve: false,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [],
downstreamBoardLoads: [],
},
],
};
const target = {
...groupSection("lighting-2", "lighting", 2, 20),
circuits: [
{
id: "circuit-target",
circuitListId: "list-1",
sectionId: "lighting-2",
equipmentIdentifier: "-1F2.3",
sortOrder: 20,
isReserve: false,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows: [],
downstreamBoardLoads: [],
},
],
};
assert.equal(
canMoveCircuitToGroup(
[source, target],
"circuit-source",
"lighting-2"
),
true
);
assert.deepEqual(
buildCircuitGroupMovePlan({
sections: [source, target],
circuitId: "circuit-source",
targetSectionId: "lighting-2",
placement: {
kind: "before",
targetCircuitId: "circuit-target",
},
}),
{
circuitId: "circuit-source",
circuitListId: "list-1",
expectedSectionId: "lighting-1",
targetSectionId: "lighting-2",
expectedEquipmentIdentifier: "-1F1.5",
targetEquipmentIdentifier: "-1F2.4",
expectedSortOrder: 10,
targetSortOrder: 10,
}
);
assert.equal(
canMoveCircuitToGroup(
[
source,
groupSection("single-1", "single_phase", 1, 20),
],
"circuit-source",
"single-1"
),
false
);
});
it("captures a complete populated group for warned deletion", () => {
const section = {
...groupSection("lighting-1", "lighting", 1, 10),
components: [
{
id: "rcd-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
protectionDevice: {
type: "FI" as const,
ratedCurrentA: 40,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.1",
displayName: "Flurlicht",
sortOrder: 10,
voltage: 230,
isReserve: false,
circuitTotalPower: 0.1,
circuitApparentPower: 0.1 / 0.95,
circuitCosPhi: 0.95,
downstreamBoardLoads: [],
protectionDevice: {
type: "LS" as const,
ratedCurrentA: 10,
tripCharacteristic: "B" as const,
},
deviceRows: [
{
id: "row-1",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte Flur",
phaseType: "single_phase",
quantity: 2,
manualQuantity: 2,
powerPerUnit: 0.05,
simultaneityFactor: 1,
cosPhi: 0.95,
overriddenFields: "[\"displayName\"]",
rowTotalPower: 0.1,
},
],
},
],
};
const snapshot = buildCircuitGroupSubtreeSnapshot(
section,
"list-1"
);
assert.deepEqual(summarizeCircuitGroupSubtree(snapshot), {
circuitCount: 1,
deviceRowCount: 1,
protectionComponentCount: 1,
});
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.linkedProjectDeviceId,
"device-1"
);
assert.equal(
snapshot.circuits[0].circuit.deviceRows[0]
.overriddenFields,
"[\"displayName\"]"
);
assert.deepEqual(snapshot.circuits[0].protectionDevice, {
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
});
});
it("formats the agreed identifiers including the leading hyphen", () => {
assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1),
"-1F1.0"
);
assert.equal(formatGroupRcdIdentifier("lighting", 1), "-1Q1.0");
assert.equal(
formatGroupedCircuitIdentifier("lighting", 1, 2),
"-1F1.2"
);
assert.equal(
formatGroupedCircuitIdentifier("single_phase", 3, 7),
"-2F3.7"
);
assert.equal(
formatGroupedCircuitIdentifier("three_phase", 2, 1),
"-3F2.1"
);
});
it("parses circuit and group component identifiers", () => {
assert.deepEqual(parseGroupedEquipmentIdentifier("-1F2.4"), {
category: "lighting",
groupNumber: 2,
kind: "circuit",
circuitNumber: 4,
});
assert.deepEqual(parseGroupedEquipmentIdentifier("-2F3.0"), {
category: "single_phase",
groupNumber: 3,
kind: "group_upstream_protection",
circuitNumber: null,
});
assert.deepEqual(parseGroupedEquipmentIdentifier("-3Q1.0"), {
category: "three_phase",
groupNumber: 1,
kind: "group_rcd",
circuitNumber: null,
});
});
it("rejects outdated, malformed and unsupported identifiers", () => {
for (const identifier of [
"-1F1",
"1F1.1",
"-1Q1.2",
"-4F1.1",
"-1F0.1",
"-1F1.01",
]) {
assert.equal(parseGroupedEquipmentIdentifier(identifier), null);
}
});
it("uses the highest target-group suffix plus one without filling gaps", () => {
assert.equal(
getNextGroupedCircuitIdentifier("lighting", 2, [
"-1F2.1",
"-1F2.3",
"-1F2.7",
"-1F1.12",
"-2F2.9",
"-1F2.0",
"-1Q2.0",
"-1F8",
]),
"-1F2.8"
);
});
it("uses the highest group number in the selected category plus one", () => {
assert.equal(
getNextCircuitGroupNumber("lighting", [
{ category: "lighting", groupNumber: 1 },
{ category: "lighting", groupNumber: 4 },
{ category: "single_phase", groupNumber: 9 },
]),
5
);
});
it("rejects non-positive group and circuit numbers", () => {
assert.throws(
() => formatGroupedCircuitIdentifier("lighting", 0, 1),
RangeError
);
assert.throws(
() => formatGroupedCircuitIdentifier("lighting", 1, 0),
RangeError
);
});
});
function groupSection(
id: string,
category: "lighting" | "single_phase" | "three_phase",
groupNumber: number,
sortOrder: number
) {
return {
id,
key: id,
displayName: id,
prefix: `-${category === "lighting" ? 1 : category === "single_phase" ? 2 : 3}F${groupNumber}.`,
sortOrder,
category,
groupNumber,
sectionTotalPower: 0,
components: [],
circuits: [],
};
}
describe("circuit group renumber planning", () => {
const groups = [
{
id: "lighting-1",
category: "lighting" as const,
groupNumber: 1,
prefix: "-1F1.",
circuits: [
{ id: "circuit-1", equipmentIdentifier: "-1F1.1" },
{ id: "circuit-2", equipmentIdentifier: "-1F1.7" },
],
components: [
{
id: "fuse-1",
role: "group_upstream_protection" as const,
equipmentIdentifier: "-1F1.0",
},
{
id: "rcd-1",
role: "group_residual_current_protection" as const,
equipmentIdentifier: "-1Q1.0",
},
],
},
{
id: "lighting-2",
category: "lighting" as const,
groupNumber: 2,
prefix: "-1F2.",
circuits: [
{ id: "circuit-3", equipmentIdentifier: "-1F2.4" },
],
components: [],
},
{
id: "single-1",
category: "single_phase" as const,
groupNumber: 1,
prefix: "-2F1.",
circuits: [],
components: [],
},
];
it("preserves circuit suffixes and maps optional group components", () => {
const plan = createCircuitGroupRenumberPlan(groups, [
{ groupId: "lighting-1", targetGroupNumber: 3 },
]);
assert.deepEqual(plan.groups[0], {
groupId: "lighting-1",
category: "lighting",
expectedGroupNumber: 1,
targetGroupNumber: 3,
expectedPrefix: "-1F1.",
targetPrefix: "-1F3.",
circuits: [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1.1",
targetEquipmentIdentifier: "-1F3.1",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F1.7",
targetEquipmentIdentifier: "-1F3.7",
},
],
components: [
{
componentId: "fuse-1",
expectedEquipmentIdentifier: "-1F1.0",
targetEquipmentIdentifier: "-1F3.0",
},
{
componentId: "rcd-1",
expectedEquipmentIdentifier: "-1Q1.0",
targetEquipmentIdentifier: "-1Q3.0",
},
],
});
});
it("allows a number swap when every affected group participates", () => {
const plan = createCircuitGroupRenumberPlan(groups, [
{ groupId: "lighting-1", targetGroupNumber: 2 },
{ groupId: "lighting-2", targetGroupNumber: 1 },
]);
assert.deepEqual(
plan.groups.map((group) => [
group.expectedGroupNumber,
group.targetGroupNumber,
]),
[
[1, 2],
[2, 1],
]
);
});
it("rejects collisions, no-op requests and mismatched current BMKs", () => {
assert.throws(
() =>
createCircuitGroupRenumberPlan(groups, [
{ groupId: "lighting-1", targetGroupNumber: 2 },
]),
/unchanged group/
);
assert.throws(
() =>
createCircuitGroupRenumberPlan(groups, [
{ groupId: "lighting-1", targetGroupNumber: 1 },
]),
/must change/
);
assert.throws(
() =>
createCircuitGroupRenumberPlan(
[
{
...groups[0],
circuits: [
{
id: "circuit-1",
equipmentIdentifier: "-1F9.1",
},
],
},
],
[{ groupId: "lighting-1", targetGroupNumber: 3 }]
),
/Circuit BMK does not match/
);
});
});
describe("circuit group move planning", () => {
const sourceGroup = {
id: "lighting-1",
category: "lighting" as const,
groupNumber: 1,
prefix: "-1F1.",
};
const targetGroup = {
id: "lighting-2",
category: "lighting" as const,
groupNumber: 2,
prefix: "-1F2.",
};
it("uses the highest target suffix plus one and stores both positions", () => {
assert.deepEqual(
createCircuitGroupMovePlan({
circuit: {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.7",
sortOrder: 20,
},
sourceGroup,
targetGroup,
targetCircuitEquipmentIdentifiers: [
"-1F2.1",
"-1F2.4",
"-1F2.9",
],
targetSortOrder: 40,
}),
{
circuitId: "circuit-1",
circuitListId: "list-1",
expectedSectionId: "lighting-1",
targetSectionId: "lighting-2",
expectedEquipmentIdentifier: "-1F1.7",
targetEquipmentIdentifier: "-1F2.10",
expectedSortOrder: 20,
targetSortOrder: 40,
}
);
});
it("does not fill target gaps", () => {
assert.equal(
createCircuitGroupMovePlan({
circuit: {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.2",
sortOrder: 10,
},
sourceGroup,
targetGroup,
targetCircuitEquipmentIdentifiers: ["-1F2.1", "-1F2.3"],
targetSortOrder: 20,
}).targetEquipmentIdentifier,
"-1F2.4"
);
});
it("rejects cross-category, same-group and mismatched source moves", () => {
const base = {
circuit: {
id: "circuit-1",
circuitListId: "list-1",
sectionId: "lighting-1",
equipmentIdentifier: "-1F1.1",
sortOrder: 10,
},
sourceGroup,
targetGroup,
targetCircuitEquipmentIdentifiers: [] as string[],
targetSortOrder: 10,
};
assert.throws(
() =>
createCircuitGroupMovePlan({
...base,
targetGroup: {
id: "single-1",
category: "single_phase",
groupNumber: 1,
prefix: "-2F1.",
},
}),
/same category/
);
assert.throws(
() =>
createCircuitGroupMovePlan({
...base,
targetGroup: sourceGroup,
}),
/different groups/
);
assert.throws(
() =>
createCircuitGroupMovePlan({
...base,
circuit: {
...base.circuit,
equipmentIdentifier: "-1F9.1",
},
}),
/does not match/
);
});
});