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, 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 ", 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); }); });