forked from jappel/leistungsbilanz-ts
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>
315 lines
9.3 KiB
TypeScript
315 lines
9.3 KiB
TypeScript
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/
|
||
);
|
||
});
|
||
});
|