In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
|
||
import {
|
||
addEstimateGroup,
|
||
addEstimateRow,
|
||
createCopyName,
|
||
createEstimateRow,
|
||
estimateBalanceFingerprint,
|
||
moveEstimateGroup,
|
||
moveEstimateRow,
|
||
normalizeEstimateSortOrder,
|
||
removeEstimateGroup,
|
||
removeEstimateRow,
|
||
toSaveableEstimateBalance,
|
||
updateEstimateGroup,
|
||
updateEstimateRow,
|
||
validateEstimateBalance,
|
||
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
|
||
import {
|
||
estimateNumberRanges,
|
||
formatAmpere,
|
||
formatDecimalInput,
|
||
formatDeltaPercent,
|
||
formatIsoDateGerman,
|
||
formatKw,
|
||
formatSignedKw,
|
||
getTodayIsoDate,
|
||
parseGermanDecimal,
|
||
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
||
import {
|
||
ApiRequestError,
|
||
createEstimateBalance,
|
||
deleteEstimateBalance,
|
||
getEstimateBalance,
|
||
getEstimateComparison,
|
||
isRevisionConflictError,
|
||
listEstimateBalances,
|
||
updateEstimateBalance,
|
||
} from "../src/frontend/utils/api.js";
|
||
|
||
function sequentialIds(prefix = "id") {
|
||
let next = 0;
|
||
return () => `${prefix}-${++next}`;
|
||
}
|
||
|
||
function sampleBalance(): EstimateBalanceSnapshot {
|
||
return {
|
||
id: "balance-1",
|
||
projectId: "project-1",
|
||
name: "Entwurfsplanung",
|
||
workStage: "LPH 3",
|
||
dateIso: "2026-09-26",
|
||
reserveAvPercent: 20,
|
||
reserveSvPercent: 10,
|
||
sortOrder: 0,
|
||
groups: [
|
||
{
|
||
id: "group-410",
|
||
costGroup: "410",
|
||
title: "Sanitärtechnik",
|
||
editor: null,
|
||
sortOrder: 0,
|
||
rows: [
|
||
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
|
||
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
|
||
],
|
||
},
|
||
{
|
||
id: "group-440",
|
||
costGroup: "440",
|
||
title: "Starkstromanlagen",
|
||
editor: "ELT",
|
||
sortOrder: 1,
|
||
rows: [],
|
||
},
|
||
],
|
||
};
|
||
}
|
||
|
||
describe("estimate balance number input", () => {
|
||
it("accepts a comma as decimal separator", () => {
|
||
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
|
||
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
|
||
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
|
||
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
|
||
});
|
||
|
||
it("rejects dots, empty input and garbage", () => {
|
||
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
|
||
assert.equal(parseGermanDecimal(text).ok, false, text);
|
||
}
|
||
const dot = parseGermanDecimal("0.8");
|
||
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
|
||
});
|
||
|
||
it("checks the server ranges of the document fields", () => {
|
||
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
|
||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
|
||
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
|
||
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
|
||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
|
||
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
|
||
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
|
||
});
|
||
|
||
it("formats input text so it parses back unchanged", () => {
|
||
assert.equal(formatDecimalInput(1200), "1200");
|
||
assert.equal(formatDecimalInput(0.8), "0,8");
|
||
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
|
||
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
|
||
});
|
||
});
|
||
|
||
describe("estimate balance display format", () => {
|
||
it("rounds half away from zero like Excel", () => {
|
||
assert.equal(formatKw(40.175), "40,18");
|
||
assert.equal(formatKw(30.24), "30,24");
|
||
assert.equal(formatKw(1234.5), "1.234,50");
|
||
assert.equal(formatKw(-0.001), "0,00");
|
||
assert.equal(formatAmpere(57.75), "57,75");
|
||
assert.equal(formatAmpere(235.749), "235,75");
|
||
});
|
||
|
||
it("shows signed deltas and percentages", () => {
|
||
assert.equal(formatSignedKw(1.5), "+1,50");
|
||
assert.equal(formatSignedKw(-1.825), "-1,83");
|
||
assert.equal(formatSignedKw(0.001), "0,00");
|
||
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
|
||
assert.equal(formatDeltaPercent(-5), "-5,0 %");
|
||
assert.equal(formatDeltaPercent(null), "–");
|
||
});
|
||
|
||
it("formats dates", () => {
|
||
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
|
||
assert.equal(formatIsoDateGerman(null), "–");
|
||
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
|
||
});
|
||
});
|
||
|
||
describe("estimate balance document editing", () => {
|
||
it("adds rows at the end or after a row with continuous sort orders", () => {
|
||
const createId = sequentialIds("new");
|
||
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
|
||
assert.equal(appended.rowId, "new-1");
|
||
assert.deepEqual(
|
||
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
|
||
);
|
||
const inserted = addEstimateRow(sampleBalance(), "group-410", {
|
||
afterRowId: "row-a",
|
||
createId,
|
||
});
|
||
assert.deepEqual(
|
||
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
|
||
);
|
||
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
|
||
assert.equal(unknownGroup.rowId, null);
|
||
});
|
||
|
||
it("creates rows with neutral defaults", () => {
|
||
const row = createEstimateRow(3, () => "row-x");
|
||
assert.deepEqual(row, {
|
||
id: "row-x",
|
||
sortOrder: 3,
|
||
supply: null,
|
||
circuitRef: null,
|
||
name: "",
|
||
quantity: 1,
|
||
unit: "piece",
|
||
connection: null,
|
||
powerPerUnitW: 0,
|
||
simultaneityFactor: 1,
|
||
cosPhi: 1,
|
||
safetySupply: false,
|
||
remark: null,
|
||
});
|
||
});
|
||
|
||
it("updates, removes and moves rows without touching the input", () => {
|
||
const original = sampleBalance();
|
||
const updated = updateEstimateRow(original, "group-410", "row-b", {
|
||
quantity: 3,
|
||
safetySupply: true,
|
||
});
|
||
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
|
||
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
|
||
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
|
||
|
||
const removed = removeEstimateRow(original, "group-410", "row-a");
|
||
assert.deepEqual(
|
||
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||
[["row-b", 0]]
|
||
);
|
||
|
||
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
|
||
assert.deepEqual(
|
||
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||
[["row-b", 0], ["row-a", 1]]
|
||
);
|
||
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
|
||
});
|
||
|
||
it("adds, edits, moves and removes groups", () => {
|
||
const added = addEstimateGroup(
|
||
sampleBalance(),
|
||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||
() => "group-480"
|
||
);
|
||
assert.equal(added.groupId, "group-480");
|
||
assert.deepEqual(
|
||
added.balance.groups.map((group) => [group.id, group.sortOrder]),
|
||
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
|
||
);
|
||
assert.equal(added.balance.groups[2]!.editor, null);
|
||
|
||
const edited = updateEstimateGroup(added.balance, "group-480", {
|
||
costGroup: "481",
|
||
editor: "MSR",
|
||
});
|
||
assert.equal(edited.groups[2]!.costGroup, "481");
|
||
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
|
||
|
||
const movedUp = moveEstimateGroup(edited, "group-480", -1);
|
||
assert.deepEqual(
|
||
movedUp.groups.map((group) => [group.id, group.sortOrder]),
|
||
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
|
||
);
|
||
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
|
||
|
||
const removed = removeEstimateGroup(movedUp, "group-410");
|
||
assert.deepEqual(
|
||
removed.groups.map((group) => [group.id, group.sortOrder]),
|
||
[["group-480", 0], ["group-440", 1]]
|
||
);
|
||
assert.equal(removeEstimateGroup(removed, "missing"), removed);
|
||
});
|
||
|
||
it("orders loaded documents by sortOrder", () => {
|
||
const balance = sampleBalance();
|
||
const shuffled: EstimateBalanceSnapshot = {
|
||
...balance,
|
||
groups: [
|
||
{ ...balance.groups[1]!, sortOrder: 7 },
|
||
{
|
||
...balance.groups[0]!,
|
||
sortOrder: 2,
|
||
rows: [
|
||
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
|
||
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
const normalized = normalizeEstimateSortOrder(shuffled);
|
||
assert.deepEqual(
|
||
normalized.groups.map((group) => [group.id, group.sortOrder]),
|
||
[["group-410", 0], ["group-440", 1]]
|
||
);
|
||
assert.deepEqual(
|
||
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||
[["row-a", 0], ["row-b", 1]]
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("estimate balance saving", () => {
|
||
it("builds the trimmed server document with null for empty optional texts", () => {
|
||
let balance = sampleBalance();
|
||
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
|
||
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
|
||
balance = updateEstimateRow(balance, "group-410", "row-a", {
|
||
name: " Hebeanlage ",
|
||
supply: " ELT ",
|
||
circuitRef: "",
|
||
remark: " ",
|
||
});
|
||
const saveable = toSaveableEstimateBalance(balance);
|
||
assert.equal(saveable.name, "Entwurf");
|
||
assert.equal(saveable.workStage, null);
|
||
assert.equal(saveable.dateIso, null);
|
||
assert.equal(saveable.groups[1]!.title, "Starkstrom");
|
||
assert.equal(saveable.groups[1]!.editor, null);
|
||
assert.deepEqual(
|
||
{
|
||
name: saveable.groups[0]!.rows[0]!.name,
|
||
supply: saveable.groups[0]!.rows[0]!.supply,
|
||
circuitRef: saveable.groups[0]!.rows[0]!.circuitRef,
|
||
remark: saveable.groups[0]!.rows[0]!.remark,
|
||
},
|
||
{ name: "Hebeanlage", supply: "ELT", circuitRef: null, remark: null }
|
||
);
|
||
});
|
||
|
||
it("copies only snapshot fields", () => {
|
||
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
|
||
balance.uiState = "leak";
|
||
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
|
||
});
|
||
|
||
it("treats whitespace-only changes as unchanged", () => {
|
||
const balance = sampleBalance();
|
||
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
|
||
name: "Hebeanlage ",
|
||
});
|
||
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
|
||
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
|
||
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
|
||
});
|
||
|
||
it("reports documents the server would reject", () => {
|
||
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
|
||
const invalid = updateEstimateGroup(
|
||
{ ...sampleBalance(), name: " " },
|
||
"group-440",
|
||
{ title: " " }
|
||
);
|
||
assert.equal(validateEstimateBalance(invalid).length, 2);
|
||
});
|
||
|
||
it("names copies", () => {
|
||
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
|
||
assert.equal(createCopyName("x".repeat(200)).length, 200);
|
||
});
|
||
});
|
||
|
||
describe("estimate balance frontend API", () => {
|
||
it("calls the estimate balance endpoints", async () => {
|
||
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
|
||
[];
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = async (input, init) => {
|
||
requests.push({
|
||
url: String(input),
|
||
method: init?.method ?? "GET",
|
||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||
...(init?.keepalive ? { keepalive: true } : {}),
|
||
});
|
||
return new Response(JSON.stringify({}), {
|
||
status: 200,
|
||
headers: { "Content-Type": "application/json" },
|
||
});
|
||
};
|
||
try {
|
||
const balance = sampleBalance();
|
||
await listEstimateBalances("project-1");
|
||
await getEstimateBalance("project-1", "balance-1");
|
||
await createEstimateBalance(
|
||
"project-1",
|
||
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
|
||
4
|
||
);
|
||
await createEstimateBalance(
|
||
"project-1",
|
||
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
|
||
5
|
||
);
|
||
await updateEstimateBalance("project-1", balance, 6);
|
||
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
|
||
await deleteEstimateBalance("project-1", "balance-1", 8);
|
||
await getEstimateComparison("project-1", "balance-1");
|
||
const base = "/api/projects/project-1/estimate-balances";
|
||
assert.deepEqual(requests, [
|
||
{ url: base, method: "GET", body: undefined },
|
||
{ url: `${base}/balance-1`, method: "GET", body: undefined },
|
||
{
|
||
url: base,
|
||
method: "POST",
|
||
body: {
|
||
name: "Vorplanung",
|
||
workStage: null,
|
||
dateIso: "2026-09-26",
|
||
template: "kg400",
|
||
expectedRevision: 4,
|
||
},
|
||
},
|
||
{
|
||
url: base,
|
||
method: "POST",
|
||
body: {
|
||
name: "Kopie",
|
||
template: "copy",
|
||
copyFromBalanceId: "balance-1",
|
||
expectedRevision: 5,
|
||
},
|
||
},
|
||
{
|
||
url: `${base}/balance-1`,
|
||
method: "PUT",
|
||
body: { expectedRevision: 6, balance },
|
||
},
|
||
{
|
||
url: `${base}/balance-1`,
|
||
method: "PUT",
|
||
body: { expectedRevision: 7, balance },
|
||
keepalive: true,
|
||
},
|
||
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
|
||
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
|
||
]);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
});
|
||
|
||
it("exposes status and code of failed requests", async () => {
|
||
const originalFetch = globalThis.fetch;
|
||
globalThis.fetch = async () =>
|
||
new Response(
|
||
JSON.stringify({
|
||
error: "Projektstand veraltet.",
|
||
code: "PROJECT_REVISION_CONFLICT",
|
||
}),
|
||
{ status: 409, headers: { "Content-Type": "application/json" } }
|
||
);
|
||
try {
|
||
await assert.rejects(
|
||
updateEstimateBalance("project-1", sampleBalance(), 1),
|
||
(error: unknown) => {
|
||
assert.ok(error instanceof ApiRequestError);
|
||
assert.equal(error.message, "Projektstand veraltet.");
|
||
assert.equal(error.status, 409);
|
||
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
|
||
assert.equal(isRevisionConflictError(error), true);
|
||
return true;
|
||
}
|
||
);
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
}
|
||
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
|
||
assert.equal(isRevisionConflictError(new Error("x")), false);
|
||
});
|
||
});
|