Editing "Anzahl" on a row with linked Revit objects set manualQuantity to the new total, so the row no longer matched its objects and detaching them failed with "row.manualQuantity must not exceed row.quantity". Such rows now lock "Anzahl" in the editor. The new optional columns "Anz. manuell" (editable) and "Anz. Revit" (read-only) show both shares; stored column layouts get them hidden next to "Anzahl". The device-row update derives the total from a manual-only edit and rejects any change that breaks quantity = manual + Revit, for every source. Two history fixtures inserted rows with quantity 1 and the column default manualQuantity 0, a state no runtime path creates; they now insert 1/1. Undoing "Revit-Objekte lösen" re-validated planning-value conflicts that the user had confirmed on the original assignment and was rejected. Undo/redo of row assignments now restores the accepted state without a new confirmation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
277 lines
9.7 KiB
TypeScript
277 lines
9.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import {
|
|
allColumns,
|
|
buildCircuitEditPatch,
|
|
buildDeviceRowEditPatch,
|
|
defaultVisibleColumnKeys,
|
|
formatValue,
|
|
formatCellValue,
|
|
getCircuitSectionLabel,
|
|
getProjectColumnLayoutStorageKey,
|
|
isGridEditorControlTarget,
|
|
getBlockSortValue,
|
|
getCellKind,
|
|
getCircuitValue,
|
|
getDeviceValue,
|
|
parseStoredColumnLayout,
|
|
parseNumeric,
|
|
} from "../src/frontend/utils/circuit-grid-model.js";
|
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
|
|
|
|
const device: CircuitTreeDeviceRowDto = {
|
|
id: "row-1",
|
|
sortOrder: 10,
|
|
name: "Technical light",
|
|
displayName: "Office light",
|
|
phaseType: "single_phase",
|
|
roomNumberSnapshot: "1.01",
|
|
roomNameSnapshot: "Office",
|
|
quantity: 2,
|
|
manualQuantity: 2,
|
|
powerPerUnit: 0.05,
|
|
simultaneityFactor: 0.8,
|
|
rowTotalPower: 0.08,
|
|
remark: "Device remark",
|
|
};
|
|
|
|
const circuit: CircuitTreeCircuitDto = {
|
|
id: "circuit-1",
|
|
circuitListId: "list-1",
|
|
sectionId: "section-1",
|
|
equipmentIdentifier: "-1F1",
|
|
displayName: "Lighting circuit",
|
|
sortOrder: 10,
|
|
protectionDevice: {
|
|
type: "LS",
|
|
ratedCurrentA: 16,
|
|
tripCharacteristic: "B",
|
|
},
|
|
cableType: "NYM-J",
|
|
cableCrossSection: "1.5 mm²",
|
|
cableLength: 20,
|
|
voltage: 230,
|
|
controlRequirement: "DALI",
|
|
isReserve: false,
|
|
remark: "Circuit remark",
|
|
circuitTotalPower: 0.08,
|
|
circuitApparentPower: 0.08,
|
|
circuitCosPhi: 1,
|
|
deviceRows: [device],
|
|
downstreamBoardLoads: [],
|
|
};
|
|
|
|
describe("circuit grid model", () => {
|
|
it("does not route clicks from active editor controls back to the grid cell", () => {
|
|
const inputTarget = {
|
|
closest: (selector: string) =>
|
|
selector === "input, select, textarea" ? {} : null,
|
|
};
|
|
const cellTarget = { closest: () => null };
|
|
|
|
assert.equal(isGridEditorControlTarget(inputTarget), true);
|
|
assert.equal(isGridEditorControlTarget(cellTarget), false);
|
|
assert.equal(isGridEditorControlTarget(null), false);
|
|
});
|
|
|
|
it("keeps the equipment identifier locked as the first column", () => {
|
|
assert.equal(allColumns[0].key, "equipmentIdentifier");
|
|
assert.equal(allColumns[0].label, "SK-Nr.");
|
|
assert.equal(
|
|
allColumns[0].fullLabel,
|
|
"Stromkreisnummer / Betriebsmittelkennzeichen"
|
|
);
|
|
assert.equal(allColumns[0].locked, true);
|
|
});
|
|
|
|
it("stores valid column layouts under a project-specific key", () => {
|
|
assert.equal(
|
|
getProjectColumnLayoutStorageKey("project-1"),
|
|
"circuitTreeEditor.columnLayout.v3.project-1"
|
|
);
|
|
const layout = parseStoredColumnLayout(
|
|
JSON.stringify({
|
|
order: ["displayName", "equipmentIdentifier"],
|
|
visible: [
|
|
"displayName",
|
|
"equipmentIdentifier",
|
|
"unknown",
|
|
],
|
|
})
|
|
);
|
|
assert.ok(layout);
|
|
assert.equal(layout.order[0], "equipmentIdentifier");
|
|
assert.deepEqual(layout.visible, [
|
|
"displayName",
|
|
"equipmentIdentifier",
|
|
]);
|
|
assert.equal(parseStoredColumnLayout("{invalid"), null);
|
|
});
|
|
|
|
it("adds new quantity columns hidden and next to Anzahl in stored layouts", () => {
|
|
const layout = parseStoredColumnLayout(
|
|
JSON.stringify({
|
|
order: ["equipmentIdentifier", "remark", "quantity", "displayName"],
|
|
visible: ["equipmentIdentifier", "quantity"],
|
|
})
|
|
);
|
|
assert.ok(layout);
|
|
const quantityIndex = layout.order.indexOf("quantity");
|
|
assert.deepEqual(
|
|
layout.order.slice(quantityIndex, quantityIndex + 3),
|
|
["quantity", "manualQuantity", "externalQuantity"]
|
|
);
|
|
assert.deepEqual(layout.visible, ["equipmentIdentifier", "quantity"]);
|
|
assert.equal(defaultVisibleColumnKeys.includes("manualQuantity"), false);
|
|
assert.equal(defaultVisibleColumnKeys.includes("externalQuantity"), false);
|
|
});
|
|
|
|
it("edits the manual share only on existing device rows", () => {
|
|
assert.equal(getCellKind("deviceRow", "manualQuantity"), "deviceField");
|
|
assert.equal(getCellKind("circuitCompact", "manualQuantity"), "deviceField");
|
|
assert.equal(getCellKind("placeholder", "manualQuantity"), "readonly");
|
|
assert.equal(getCellKind("reserveCircuit", "manualQuantity"), "readonly");
|
|
assert.equal(getCellKind("deviceRow", "externalQuantity"), "computed");
|
|
assert.deepEqual(buildDeviceRowEditPatch("manualQuantity", "2"), { manualQuantity: 2 });
|
|
assert.equal(formatCellValue(2, "externalQuantity"), "2 St.");
|
|
});
|
|
|
|
it("shows stable circuit sections with German labels", () => {
|
|
assert.equal(
|
|
getCircuitSectionLabel({
|
|
key: "lighting",
|
|
displayName: "Lighting",
|
|
category: "lighting",
|
|
}),
|
|
"Lighting"
|
|
);
|
|
assert.equal(
|
|
getCircuitSectionLabel({
|
|
key: "lighting",
|
|
displayName: "Lighting",
|
|
}),
|
|
"Licht"
|
|
);
|
|
assert.equal(
|
|
getCircuitSectionLabel({
|
|
key: "custom",
|
|
displayName: "Sonderbereich",
|
|
}),
|
|
"Sonderbereich"
|
|
);
|
|
});
|
|
|
|
it("maps shared fields to the correct level for every row shape", () => {
|
|
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
|
|
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
|
|
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
|
|
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField");
|
|
assert.equal(getCellKind("reserveCircuit", "displayName"), "circuitField");
|
|
assert.equal(getCellKind("reserveCircuit", "remark"), "circuitField");
|
|
assert.equal(getCellKind("reserveCircuit", "powerPerUnit"), "deviceField");
|
|
assert.equal(getCellKind("placeholder", "displayName"), "deviceField");
|
|
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
|
|
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
|
|
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
|
|
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
|
|
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
|
|
});
|
|
|
|
it("projects circuit and device values without mixing ownership", () => {
|
|
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
|
|
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
|
|
assert.equal(getCircuitValue(circuit, "protectionSummary"), "LS · 16 A · B");
|
|
assert.equal(
|
|
getCircuitValue(
|
|
{
|
|
...circuit,
|
|
protectionDevice: {
|
|
type: "FI_LS",
|
|
ratedCurrentA: 16,
|
|
tripCharacteristic: "B",
|
|
rcdType: "A",
|
|
ratedResidualCurrentMa: 30,
|
|
},
|
|
},
|
|
"protectionSummary"
|
|
),
|
|
"FI/LS · 16 A · B · Typ A · 30 mA"
|
|
);
|
|
assert.equal(getCircuitValue(circuit, "voltage"), 230);
|
|
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
|
|
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
|
});
|
|
|
|
it("formats displayed numeric values without changing their stored precision", () => {
|
|
assert.equal(formatValue(0.123456, "powerPerUnit"), "0,123");
|
|
assert.equal(formatValue(0.87654, "simultaneityFactor"), "0,88");
|
|
assert.equal(formatValue(230.4, "voltage"), "230");
|
|
assert.equal(formatValue(16, "protectionRatedCurrent"), "16");
|
|
assert.equal(formatValue("single_phase", "phaseType"), "1-phasig");
|
|
assert.equal(formatValue("three_phase", "phaseType"), "3-phasig");
|
|
assert.equal(formatCellValue(2, "quantity"), "2 St.");
|
|
assert.equal(formatCellValue(0.123456, "powerPerUnit"), "0,123 kW");
|
|
assert.equal(formatCellValue(1.25, "rowTotalPower"), "1,25 kW");
|
|
assert.equal(formatCellValue(undefined, "circuitTotalPower"), "-");
|
|
});
|
|
|
|
it("uses circuit display values for block sorting and falls back to the first device", () => {
|
|
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
|
|
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
|
|
assert.equal(getBlockSortValue(circuit, "quantity"), 2);
|
|
});
|
|
|
|
it("parses numeric drafts and rejects invalid values", () => {
|
|
assert.equal(parseNumeric("quantity", " 1500 "), 1500);
|
|
assert.equal(parseNumeric("powerPerUnit", " 1,25 "), 1.25);
|
|
assert.equal(parseNumeric("quantity", ""), undefined);
|
|
assert.throws(() => parseNumeric("quantity", "two"), /Ungültiger Zahlenwert/);
|
|
});
|
|
|
|
it("rejects a dot instead of silently misreading it as a thousands separator", () => {
|
|
// "1.500" typed with German thousands-separator intent (meaning 1500)
|
|
// must never silently become 1.5 — it must fail loudly instead.
|
|
assert.throws(
|
|
() => parseNumeric("cableLength", "1.500"),
|
|
/Dezimalstellen mit Komma eingeben/
|
|
);
|
|
assert.throws(
|
|
() => parseNumeric("powerPerUnit", "2.5"),
|
|
/Dezimalstellen mit Komma eingeben/
|
|
);
|
|
});
|
|
|
|
it("builds nullable circuit command patches from grid drafts", () => {
|
|
assert.deepEqual(buildCircuitEditPatch("voltage", ""), {
|
|
voltage: null,
|
|
});
|
|
assert.deepEqual(
|
|
buildCircuitEditPatch("controlRequirement", " DALI "),
|
|
{ controlRequirement: "DALI" }
|
|
);
|
|
assert.deepEqual(buildCircuitEditPatch("isReserve", "ja"), {
|
|
isReserve: true,
|
|
});
|
|
});
|
|
|
|
it("builds device command patches with explicit clearing semantics", () => {
|
|
assert.deepEqual(buildDeviceRowEditPatch("roomSummary", ""), {
|
|
roomNumberSnapshot: null,
|
|
roomNameSnapshot: null,
|
|
});
|
|
assert.deepEqual(
|
|
buildDeviceRowEditPatch("roomSummary", "1.01 Büro"),
|
|
{
|
|
roomNumberSnapshot: "1.01",
|
|
roomNameSnapshot: "Büro",
|
|
}
|
|
);
|
|
assert.deepEqual(buildDeviceRowEditPatch("technicalName", " Leuchte "), {
|
|
name: "Leuchte",
|
|
});
|
|
assert.throws(
|
|
() => buildDeviceRowEditPatch("quantity", ""),
|
|
/darf nicht leer sein/
|
|
);
|
|
});
|
|
});
|