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, deviceRows: [], }, ], 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, deviceRows: [], }, ], }; 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, deviceRows: [], }, ], }; 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, 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, 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/ ); }); });