forked from jappel/leistungsbilanz-ts
482 lines
16 KiB
TypeScript
482 lines
16 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 { createEstimateProjectDeviceInitialValues } from "../src/frontend/estimate-balance/estimate-project-device-prefill.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,
|
||
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 row to project device prefill", () => {
|
||
it("maps a piece row to one reusable project-device template", () => {
|
||
const balance = sampleBalance();
|
||
const group = balance.groups[0]!;
|
||
const row = {
|
||
...group.rows[0]!,
|
||
name: " Hebeanlage ",
|
||
quantity: 4,
|
||
connection: "fixed" as const,
|
||
powerPerUnitW: 1250,
|
||
simultaneityFactor: 0.8,
|
||
cosPhi: 0.9,
|
||
remark: "bauseits",
|
||
};
|
||
|
||
assert.deepEqual(createEstimateProjectDeviceInitialValues(group, row), {
|
||
name: "Hebeanlage",
|
||
displayName: "Hebeanlage",
|
||
category: "single_phase",
|
||
connectionKind: "Festanschluss",
|
||
costGroup: "410",
|
||
quantity: 1,
|
||
powerPerUnit: 1.25,
|
||
simultaneityFactor: 0.8,
|
||
cosPhi: 0.9,
|
||
remark: "bauseits",
|
||
});
|
||
});
|
||
|
||
it("leaves quantity and power blank for an area row", () => {
|
||
const balance = sampleBalance();
|
||
const group = balance.groups[1]!;
|
||
const row = {
|
||
...createEstimateRow(0, () => "area-row"),
|
||
name: "Bürofläche",
|
||
unit: "area" as const,
|
||
connection: "socket" as const,
|
||
quantity: 250,
|
||
powerPerUnitW: 15,
|
||
};
|
||
|
||
const initial = createEstimateProjectDeviceInitialValues(group, row);
|
||
assert.equal(initial.quantity, null);
|
||
assert.equal(initial.powerPerUnit, null);
|
||
assert.equal(initial.connectionKind, "Steckdose");
|
||
assert.equal(initial.costGroup, "440");
|
||
});
|
||
});
|
||
|
||
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 ",
|
||
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,
|
||
remark: saveable.groups[0]!.rows[0]!.remark,
|
||
},
|
||
{ name: "Hebeanlage", supply: "ELT", 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);
|
||
});
|
||
});
|