forked from jappel/leistungsbilanz-ts
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>
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import {
|
|
buildVisibleGridRowsWithStructure,
|
|
filterAndSortCircuitSections,
|
|
getDistinctFilterValues,
|
|
} from "../src/frontend/utils/circuit-grid-projection.js";
|
|
import type {
|
|
CircuitTreeCircuitDto,
|
|
CircuitTreeDeviceRowDto,
|
|
CircuitTreeSectionDto,
|
|
} from "../src/frontend/types.js";
|
|
import "./circuit-structure-projection.test.js";
|
|
|
|
function makeDevice(
|
|
id: string,
|
|
displayName: string,
|
|
roomNumberSnapshot: string,
|
|
sortOrder: number
|
|
): CircuitTreeDeviceRowDto {
|
|
return {
|
|
id,
|
|
sortOrder,
|
|
name: displayName,
|
|
displayName,
|
|
roomNumberSnapshot,
|
|
quantity: 1,
|
|
manualQuantity: 1,
|
|
powerPerUnit: 0.1,
|
|
simultaneityFactor: 1,
|
|
rowTotalPower: 0.1,
|
|
};
|
|
}
|
|
|
|
function makeCircuit(
|
|
id: string,
|
|
equipmentIdentifier: string,
|
|
displayName: string,
|
|
deviceRows: CircuitTreeDeviceRowDto[],
|
|
sortOrder: number
|
|
): CircuitTreeCircuitDto {
|
|
return {
|
|
id,
|
|
circuitListId: "list-1",
|
|
sectionId: "section-1",
|
|
equipmentIdentifier,
|
|
displayName,
|
|
sortOrder,
|
|
isReserve: deviceRows.length === 0,
|
|
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
|
circuitApparentPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
|
|
circuitCosPhi: 1,
|
|
deviceRows,
|
|
downstreamBoardLoads: [],
|
|
};
|
|
}
|
|
|
|
const multiDeviceCircuit = makeCircuit(
|
|
"circuit-multi",
|
|
"-1F2",
|
|
"Office lighting",
|
|
[makeDevice("device-office", "Office light", "1.01", 10), makeDevice("device-store", "Store light", "1.02", 20)],
|
|
20
|
|
);
|
|
const compactCircuit = makeCircuit(
|
|
"circuit-compact",
|
|
"-1F1",
|
|
"Corridor lighting",
|
|
[makeDevice("device-corridor", "Corridor light", "1.03", 10)],
|
|
10
|
|
);
|
|
const reserveCircuit = makeCircuit("circuit-reserve", "-1F3", "Reserve", [], 30);
|
|
|
|
const sections: CircuitTreeSectionDto[] = [
|
|
{
|
|
id: "section-1",
|
|
key: "lighting",
|
|
displayName: "Lighting",
|
|
prefix: "-1F",
|
|
sortOrder: 10,
|
|
sectionTotalPower: 0.3,
|
|
components: [],
|
|
circuits: [compactCircuit, multiDeviceCircuit, reserveCircuit],
|
|
},
|
|
];
|
|
|
|
describe("circuit grid projection", () => {
|
|
it("keeps empty sections and their free placeholders when no filter is active", () => {
|
|
const emptySections: CircuitTreeSectionDto[] = [
|
|
{
|
|
id: "empty-lighting",
|
|
key: "lighting",
|
|
displayName: "Lighting",
|
|
prefix: "-1F",
|
|
sortOrder: 10,
|
|
sectionTotalPower: 0,
|
|
components: [],
|
|
circuits: [],
|
|
},
|
|
{
|
|
id: "empty-single-phase",
|
|
key: "single_phase",
|
|
displayName: "Single-phase circuits",
|
|
prefix: "-2F",
|
|
sortOrder: 20,
|
|
sectionTotalPower: 0,
|
|
components: [],
|
|
circuits: [],
|
|
},
|
|
];
|
|
|
|
const projectedSections = filterAndSortCircuitSections(emptySections, {}, null);
|
|
const rows = buildVisibleGridRowsWithStructure(projectedSections, { headerComponents: [], footerComponents: [] });
|
|
|
|
assert.equal(projectedSections.length, 2);
|
|
assert.deepEqual(
|
|
rows.map((row) => row.rowKey),
|
|
[
|
|
"section:empty-lighting",
|
|
"placeholder:empty-lighting",
|
|
"section:empty-single-phase",
|
|
"placeholder:empty-single-phase",
|
|
]
|
|
);
|
|
});
|
|
|
|
it("keeps every device row in a circuit block when one device matches a filter", () => {
|
|
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["1.01"] }, null);
|
|
|
|
assert.deepEqual(result.map((section) => section.circuits.map((circuit) => circuit.id)), [["circuit-multi"]]);
|
|
assert.deepEqual(result[0].circuits[0].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
|
|
});
|
|
|
|
it("combines column filters across the complete circuit block", () => {
|
|
const result = filterAndSortCircuitSections(
|
|
sections,
|
|
{ equipmentIdentifier: ["-1F2"], roomNumberSnapshot: ["1.02"] },
|
|
null
|
|
);
|
|
|
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-multi"]);
|
|
});
|
|
|
|
it("treats missing device values as missing without matching populated circuits", () => {
|
|
const result = filterAndSortCircuitSections(sections, { roomNumberSnapshot: ["-"] }, null);
|
|
|
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), ["circuit-reserve"]);
|
|
});
|
|
|
|
it("sorts complete circuit blocks without changing device row order", () => {
|
|
const result = filterAndSortCircuitSections(sections, {}, { key: "equipmentIdentifier", direction: "desc" });
|
|
|
|
assert.deepEqual(result[0].circuits.map((circuit) => circuit.id), [
|
|
"circuit-reserve",
|
|
"circuit-multi",
|
|
"circuit-compact",
|
|
]);
|
|
assert.deepEqual(result[0].circuits[1].deviceRows.map((row) => row.id), ["device-office", "device-store"]);
|
|
});
|
|
|
|
it("locks the total quantity of Revit-linked rows and exposes the manual and Revit shares", () => {
|
|
const linkedDevice = { ...makeDevice("device-linked", "Steckdose", "1.04", 10), quantity: 3, manualQuantity: 1, externalObjectCount: 1 };
|
|
const linkedSections: CircuitTreeSectionDto[] = [{
|
|
...sections[0],
|
|
circuits: [makeCircuit("circuit-linked", "-1F4", "Steckdosen", [linkedDevice, makeDevice("device-manual", "Leuchte", "1.05", 20)], 10)],
|
|
}];
|
|
const rows = buildVisibleGridRowsWithStructure(linkedSections, { headerComponents: [], footerComponents: [] });
|
|
const cell = (deviceId: string, key: string) =>
|
|
rows.find((row) => row.device?.id === deviceId)!.cells.find((entry) => entry.cellKey === key)!;
|
|
|
|
assert.equal(cell("device-linked", "quantity").editable, false);
|
|
assert.match(cell("device-linked", "quantity").lockedReason ?? "", /Anz\. manuell/);
|
|
assert.equal(cell("device-linked", "manualQuantity").editable, true);
|
|
assert.equal(cell("device-linked", "manualQuantity").value, 1);
|
|
assert.equal(cell("device-linked", "externalQuantity").editable, false);
|
|
assert.equal(cell("device-linked", "externalQuantity").value, 2);
|
|
assert.equal(cell("device-manual", "quantity").editable, true);
|
|
assert.equal(cell("device-manual", "quantity").lockedReason, undefined);
|
|
assert.equal(cell("device-manual", "externalQuantity").value, 0);
|
|
});
|
|
|
|
it("builds the compact, grouped, reserve and placeholder row shapes", () => {
|
|
const rows = buildVisibleGridRowsWithStructure(sections, { headerComponents: [], footerComponents: [] });
|
|
|
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
|
"section",
|
|
"circuitCompact",
|
|
"circuitSummary",
|
|
"deviceRow",
|
|
"deviceRow",
|
|
"reserveCircuit",
|
|
"placeholder",
|
|
]);
|
|
assert.equal(rows.at(-1)?.rowKey, "placeholder:section-1");
|
|
assert.equal(
|
|
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
|
|
"-frei-"
|
|
);
|
|
const circuitSummary = rows.find((row) => row.rowType === "circuitSummary");
|
|
assert.equal(
|
|
circuitSummary?.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
|
|
multiDeviceCircuit.circuitTotalPower
|
|
);
|
|
assert.equal(
|
|
circuitSummary?.cells.some((cell) => cell.cellKey === "circuitTotalPower"),
|
|
false
|
|
);
|
|
});
|
|
|
|
it("renders one linked downstream board as one compact editable circuit row", () => {
|
|
const feeder = {
|
|
...reserveCircuit,
|
|
displayName: undefined,
|
|
isReserve: false,
|
|
circuitTotalPower: 4,
|
|
circuitApparentPower: 5,
|
|
circuitCosPhi: 0.8,
|
|
downstreamBoardLoads: [{
|
|
distributionBoardId: "board-child",
|
|
name: "Unterverteilung",
|
|
boardNumber: "UV-02",
|
|
activePowerKw: 4,
|
|
apparentPowerKva: 5,
|
|
cosPhi: 0.8,
|
|
}],
|
|
};
|
|
const rows = buildVisibleGridRowsWithStructure(
|
|
[{ ...sections[0], circuits: [feeder] }],
|
|
{ headerComponents: [], footerComponents: [] }
|
|
);
|
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
|
"section",
|
|
"circuitSummary",
|
|
"placeholder",
|
|
]);
|
|
const loadRow = rows[1];
|
|
assert.equal(
|
|
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value,
|
|
"UV-02"
|
|
);
|
|
assert.equal(
|
|
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.editValue,
|
|
""
|
|
);
|
|
assert.equal(
|
|
loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
|
|
4
|
|
);
|
|
assert.equal(
|
|
loadRow.cells.find((cell) => cell.cellKey === "displayName")?.kind,
|
|
"circuitField"
|
|
);
|
|
});
|
|
|
|
it("keeps separate read-only rows for several linked downstream boards", () => {
|
|
const feeder = {
|
|
...reserveCircuit,
|
|
isReserve: false,
|
|
circuitTotalPower: 6,
|
|
circuitApparentPower: 7,
|
|
circuitCosPhi: 6 / 7,
|
|
downstreamBoardLoads: [
|
|
{
|
|
distributionBoardId: "board-a",
|
|
name: "Unterverteilung A",
|
|
boardNumber: "UV-A",
|
|
activePowerKw: 4,
|
|
apparentPowerKva: 5,
|
|
cosPhi: 0.8,
|
|
},
|
|
{
|
|
distributionBoardId: "board-b",
|
|
name: "Unterverteilung B",
|
|
boardNumber: "UV-B",
|
|
activePowerKw: 2,
|
|
apparentPowerKva: 2,
|
|
cosPhi: 1,
|
|
},
|
|
],
|
|
};
|
|
const rows = buildVisibleGridRowsWithStructure(
|
|
[{ ...sections[0], circuits: [feeder] }],
|
|
{ headerComponents: [], footerComponents: [] }
|
|
);
|
|
assert.deepEqual(rows.map((row) => row.rowType), [
|
|
"section",
|
|
"circuitSummary",
|
|
"downstreamBoardLoad",
|
|
"downstreamBoardLoad",
|
|
"placeholder",
|
|
]);
|
|
assert.equal(rows[2].cells.every((cell) => !cell.editable), true);
|
|
});
|
|
|
|
it("shows circuit protection once on each visible circuit row", () => {
|
|
const protectedSections = sections.map((section) => ({
|
|
...section,
|
|
circuits: section.circuits.map((circuit) => ({
|
|
...circuit,
|
|
protectionDevice: {
|
|
type: "FI_LS" as const,
|
|
ratedCurrentA: 16,
|
|
tripCharacteristic: "B" as const,
|
|
rcdType: "A" as const,
|
|
ratedResidualCurrentMa: 30,
|
|
},
|
|
})),
|
|
}));
|
|
const rows = buildVisibleGridRowsWithStructure(protectedSections, { headerComponents: [], footerComponents: [] });
|
|
const protectionValue = (rowType: string) =>
|
|
rows
|
|
.find((row) => row.rowType === rowType)
|
|
?.cells.find((cell) => cell.cellKey === "protectionSummary")
|
|
?.value;
|
|
|
|
assert.equal(
|
|
protectionValue("circuitCompact"),
|
|
"FI/LS · 16 A · B · Typ A · 30 mA"
|
|
);
|
|
assert.equal(
|
|
protectionValue("circuitSummary"),
|
|
"FI/LS · 16 A · B · Typ A · 30 mA"
|
|
);
|
|
assert.equal(
|
|
protectionValue("reserveCircuit"),
|
|
"FI/LS · 16 A · B · Typ A · 30 mA"
|
|
);
|
|
assert.equal(protectionValue("deviceRow"), undefined);
|
|
});
|
|
|
|
it("normalizes structural component rows around complete circuit groups", () => {
|
|
const component = {
|
|
circuitListId: "list-1",
|
|
equipmentIdentifier: "-Q0",
|
|
name: "Hauptschalter",
|
|
role: "main_switch" as const,
|
|
placement: "header" as const,
|
|
sortOrder: 10,
|
|
};
|
|
const groupedSections = sections.map((section) => ({
|
|
...section,
|
|
components: [
|
|
{
|
|
...component,
|
|
id: "group-rcd",
|
|
sectionId: section.id,
|
|
equipmentIdentifier: "-1Q1.0",
|
|
name: "Gruppen-FI",
|
|
role: "group_residual_current_protection" as const,
|
|
placement: "group" as const,
|
|
},
|
|
],
|
|
}));
|
|
const rows = buildVisibleGridRowsWithStructure(groupedSections, {
|
|
headerComponents: [{ ...component, id: "main" }],
|
|
footerComponents: [
|
|
{
|
|
...component,
|
|
id: "actor",
|
|
equipmentIdentifier: "-K1",
|
|
name: "KNX Aktor",
|
|
role: "auxiliary",
|
|
placement: "footer",
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
rows.map((row) => row.rowType),
|
|
[
|
|
"headerComponent",
|
|
"section",
|
|
"groupComponent",
|
|
"circuitCompact",
|
|
"circuitSummary",
|
|
"deviceRow",
|
|
"deviceRow",
|
|
"reserveCircuit",
|
|
"placeholder",
|
|
"footerComponent",
|
|
]
|
|
);
|
|
assert.equal(
|
|
rows.find((row) => row.rowType === "groupComponent")?.cells.some(
|
|
(cell) => cell.editable
|
|
),
|
|
false
|
|
);
|
|
});
|
|
|
|
it("collects filter options from both circuit and device values", () => {
|
|
const values = getDistinctFilterValues(sections, [
|
|
{ key: "displayName", label: "Display name" },
|
|
{ key: "roomNumberSnapshot", label: "Room number" },
|
|
]);
|
|
|
|
assert.ok(values.displayName.includes("Office lighting"));
|
|
assert.ok(values.displayName.includes("Store light"));
|
|
assert.ok(values.roomNumberSnapshot.includes("1.03"));
|
|
});
|
|
});
|