Ctrl+C/Ctrl+V copy and paste grid cells without opening an editor, using the document copy/paste events so it also works over plain HTTP in the LAN. Protection and cable summaries copy the complete circuit record; tab-separated blocks from Excel spread over visible rows and columns. With a Ctrl/Shift multi-selection, typing, pasting and the protection dialog apply to every selected row that can take the value, including device power and GZF; other cells are skipped and reported. All of these writes use the new circuit-list.bulk-update command. It carries complete circuit.update, circuit-device-row.update and circuit-protection.update commands, applies them with the single-command rules in one transaction and stores the reversed inverses, so each edit is one undo step. The single-command apply logic is extracted for reuse. A right-click menu (also context-menu key / Shift+F10) offers copy, paste, edit, protection, cable sizing, insert circuit below, add device and delete. The protection summary is now selected by click and opened by double click, Enter or F2. Existing numbers open in the inline editor with a decimal comma, so confirming an unchanged decimal no longer fails. Refs #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
274 lines
7.9 KiB
TypeScript
274 lines
7.9 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("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/
|
|
);
|
|
});
|
|
});
|