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>
289 lines
10 KiB
TypeScript
289 lines
10 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"
|
||
);
|
||
const withoutDevice = {
|
||
...circuit,
|
||
protectionDevice: { type: "NONE" as const },
|
||
};
|
||
assert.equal(getCircuitValue(withoutDevice, "protectionSummary"), "–");
|
||
assert.equal(getCircuitValue(withoutDevice, "protectionType"), "–");
|
||
assert.equal(getCircuitValue(withoutDevice, "protectionRatedCurrent"), undefined);
|
||
// A circuit without any protection record is still open.
|
||
assert.equal(
|
||
getCircuitValue({ ...circuit, protectionDevice: undefined }, "protectionSummary"),
|
||
undefined
|
||
);
|
||
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/
|
||
);
|
||
});
|
||
});
|