leistungsbilanz-ts/tests/circuit-grid-clipboard.test.ts
Julian Appel 38019fe087 Add explicit "no protection device" state for circuits
Circuits can now record that they deliberately have no protection device
(type NONE, shown as "–"), separate from circuits whose protection is
still open. Migration 0017 makes rated_current_a nullable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:11:28 +01:00

315 lines
9.3 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 assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildCellClipboard,
formatCellEditText,
planCellWrites,
resolveClipboardCells,
resolvePasteWrites,
} from "../src/frontend/utils/circuit-grid-clipboard.js";
import { buildVisibleGridRowsWithStructure } from "../src/frontend/utils/circuit-grid-projection.js";
import type {
CircuitTreeCircuitDto,
CircuitTreeDeviceRowDto,
CircuitTreeSectionDto,
} from "../src/frontend/types.js";
function makeDevice(id: string, powerPerUnit: number): CircuitTreeDeviceRowDto {
return {
id,
sortOrder: 10,
name: `Gerät ${id}`,
displayName: `Gerät ${id}`,
phaseType: "single_phase",
quantity: 1,
manualQuantity: 1,
powerPerUnit,
simultaneityFactor: 1,
rowTotalPower: powerPerUnit,
};
}
function makeCircuit(
id: string,
deviceRows: CircuitTreeDeviceRowDto[],
extra: Partial<CircuitTreeCircuitDto> = {}
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: `-2F${id.slice(-1)}`,
displayName: id,
sortOrder: 10,
isReserve: deviceRows.length === 0,
circuitTotalPower: 0,
circuitApparentPower: 0,
circuitCosPhi: 1,
deviceRows,
downstreamBoardLoads: [],
...extra,
};
}
const source = makeCircuit("circuit-1", [makeDevice("row-1", 0.25)], {
cableType: "NYM-J",
cableCrossSection: "3x2,5",
cableLength: 18,
protectionDevice: { type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" },
});
const target = makeCircuit("circuit-2", [makeDevice("row-2", 0.1)], {
protectionDevice: { type: "LS", ratedCurrentA: 10, tripCharacteristic: "B" },
});
const multi = makeCircuit("circuit-3", [
makeDevice("row-3a", 0.1),
makeDevice("row-3b", 0.2),
]);
const reserve = makeCircuit("circuit-4", []);
const sections: CircuitTreeSectionDto[] = [
{
id: "section-1",
key: "single_phase",
displayName: "Einphasig",
prefix: "-2F",
sortOrder: 10,
sectionTotalPower: 0,
components: [],
circuits: [source, target, multi, reserve],
},
];
const rows = buildVisibleGridRowsWithStructure(sections, {
headerComponents: [],
footerComponents: [],
});
const rowByKey = (rowKey: string) => {
const row = rows.find((entry) => entry.rowKey === rowKey);
assert.ok(row, rowKey);
return row;
};
describe("circuit grid clipboard", () => {
it("formats numbers with a decimal comma for editing and copying", () => {
assert.equal(formatCellEditText(0.25), "0,25");
assert.equal(formatCellEditText(1500), "1500");
assert.equal(formatCellEditText(true), "ja");
assert.equal(formatCellEditText(undefined), "");
});
it("copies protection and cable summaries as complete circuit records", () => {
const clipboard = buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["protectionSummary"]
);
assert.equal(clipboard.cells[0][0].kind, "protection");
const plan = planCellWrites([
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "protectionSummary",
value: clipboard.cells[0][0],
},
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "cableSummary",
value: buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["cableSummary"]
).cells[0][0],
},
]);
assert.equal(plan.changedCellCount, 2);
assert.deepEqual(
plan.updates.map((update) => [update.type, update.payload]),
[
[
"circuit.update",
{
circuitId: "circuit-2",
changes: [
{ field: "cableType", value: "NYM-J" },
{ field: "cableCrossSection", value: "3x2,5" },
{ field: "cableLength", value: 18 },
],
},
],
[
"circuit-protection.update",
{
circuitId: "circuit-2",
expected: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
target: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 16,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
},
],
]
);
});
it("copies an explicit no-protection decision onto other circuits", () => {
const none = makeCircuit("circuit-5", [makeDevice("row-5", 0.1)], {
protectionDevice: { type: "NONE" },
});
const noneRows = buildVisibleGridRowsWithStructure(
[{ ...sections[0], circuits: [none, target] }],
{ headerComponents: [], footerComponents: [] }
);
const [noneRow, targetRow] = ["circuitCompact:circuit-5", "circuitCompact:circuit-2"].map(
(rowKey) => noneRows.find((row) => row.rowKey === rowKey)!
);
const clipboard = buildCellClipboard([noneRow], ["protectionSummary"]);
assert.equal(clipboard.text, "–");
const plan = planCellWrites([
{ row: targetRow, cellKey: "protectionSummary", value: clipboard.cells[0][0] },
]);
assert.deepEqual(plan.updates.map((update) => update.payload), [
{
circuitId: "circuit-2",
expected: {
circuitId: "circuit-2",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
},
target: {
circuitId: "circuit-2",
type: "NONE",
ratedCurrentA: null,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: null,
ratedResidualCurrentMa: null,
},
},
]);
});
it("fills one value into every selected row and skips rows that cannot take it", () => {
const selectedRowKeys = [
"circuitCompact:circuit-1",
"circuitCompact:circuit-2",
"circuitSummary:circuit-3",
"device:row-3a",
];
const { writes } = resolvePasteWrites({
cells: resolveClipboardCells("0,5", null),
anchor: { rowKey: "circuitCompact:circuit-1", cellKey: "powerPerUnit" },
selectedRowKeys,
rows,
columnKeys: ["equipmentIdentifier", "displayName", "powerPerUnit"],
});
assert.equal(writes.length, 4);
const plan = planCellWrites(writes);
// The summary row of a multi-device circuit has no device power.
assert.equal(plan.skippedCellCount, 1);
assert.deepEqual(
plan.updates.map((update) => [update.type, update.payload]),
["row-1", "row-2", "row-3a"].map((rowId) => [
"circuit-device-row.update",
{ rowId, changes: [{ field: "powerPerUnit", value: 0.5 }] },
])
);
});
it("pastes an Excel block down the visible rows and across visible columns", () => {
const cells = resolveClipboardCells("0,3\t0,8\r\n0,4\t0,9\r\n", null);
const { writes, overflowCellCount } = resolvePasteWrites({
cells,
anchor: { rowKey: "circuitCompact:circuit-1", cellKey: "powerPerUnit" },
selectedRowKeys: [],
rows,
columnKeys: ["displayName", "powerPerUnit", "simultaneityFactor"],
});
assert.equal(overflowCellCount, 0);
const plan = planCellWrites(writes);
assert.deepEqual(
plan.updates.map((update) => update.payload),
[
{
rowId: "row-1",
changes: [
{ field: "powerPerUnit", value: 0.3 },
{ field: "simultaneityFactor", value: 0.8 },
],
},
{
rowId: "row-2",
changes: [
{ field: "powerPerUnit", value: 0.4 },
{ field: "simultaneityFactor", value: 0.9 },
],
},
]
);
});
it("reuses structured cells only while the clipboard text is unchanged", () => {
const clipboard = buildCellClipboard(
[rowByKey("circuitCompact:circuit-1")],
["protectionSummary"]
);
assert.equal(
resolveClipboardCells(clipboard.text, clipboard)[0][0].kind,
"protection"
);
assert.equal(
resolveClipboardCells("LS 16 A", clipboard)[0][0].kind,
"text"
);
});
it("drops unchanged values, blocks shared identifiers and rejects invalid numbers", () => {
const unchanged = planCellWrites([
{
row: rowByKey("circuitCompact:circuit-1"),
cellKey: "powerPerUnit",
value: { kind: "text", text: "0,25" },
},
]);
assert.deepEqual(unchanged, {
updates: [],
changedCellCount: 0,
skippedCellCount: 0,
});
const identifiers = planCellWrites(
[
{
row: rowByKey("circuitCompact:circuit-2"),
cellKey: "equipmentIdentifier",
value: { kind: "text", text: "-2F9" },
},
],
{ allowEquipmentIdentifier: false }
);
assert.equal(identifiers.skippedCellCount, 1);
assert.equal(identifiers.updates.length, 0);
assert.throws(
() =>
planCellWrites([
{
row: rowByKey("circuitCompact:circuit-1"),
cellKey: "powerPerUnit",
value: { kind: "text", text: "1.5" },
},
]),
/Komma/
);
});
});