import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext, type DatabaseContext, } from "../src/db/database-context.js"; import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js"; import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { estimateRows } from "../src/db/schema/estimate-balances.js"; import { projects } from "../src/db/schema/projects.js"; import { createEstimateBalanceDeleteProjectCommand, createEstimateBalanceInsertProjectCommand, createEstimateBalanceSnapshot, createEstimateBalanceUpdateProjectCommand, parseEstimateBalanceSnapshot, type EstimateBalanceProjectCommand, } from "../src/estimate-balance/domain/estimate-balance-command.model.js"; import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js"; import { calculateEstimateBalance, calculateEstimateRow, compareEstimateWithDetailed, estimateDefaultGroups, matchEstimateCostGroup, roundEstimateValue, type EstimateBalanceSnapshot, type EstimateRowSnapshot, } from "../src/shared/estimate-balance/estimate-balance.js"; const round2 = (value: number) => roundEstimateValue(value, 2); let nextId = 0; const id = () => `id-${++nextId}`; function row( name: string, quantity: number, powerPerUnitW: number, simultaneityFactor: number, cosPhi: number, extra: Partial = {} ): EstimateRowSnapshot { return { id: id(), sortOrder: 0, supply: "ELT", circuitRef: null, name, quantity, unit: "piece", connection: null, powerPerUnitW, simultaneityFactor, cosPhi, safetySupply: false, remark: null, ...extra, }; } /** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */ function templateExample(): EstimateBalanceSnapshot { const rowsByGroup: Record = { "410": [ row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1), row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1), ], "420": [ row("Fernwärmestation", 1, 1000, 0.8, 1), row("Pumpen", 4, 200, 0.8, 0.9), row("Schaltschrank Heizung", 1, 2000, 0.8, 1), ], "430": [ row("Lüftungsgerät", 1, 6000, 0.8, 0.9), row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9), ], "434": [ row("Kälteaggregat Dach", 1, 1000, 0.8, 1), row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1), row("Serverraum Innengerät", 1, 1000, 0.8, 1), ], "440": [ row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95), row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95), row("Außenbeleuchtung", 10, 100, 0.4, 0.95), row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95), row("Beleuchtung MFR", 15, 50, 0.8, 0.95), row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95), row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95), row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95), row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8), row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9), row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9), ], "450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)], "460": [row("Aufzug", 1, 16000, 0.7, 0.95)], "470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)], "480": [], "": [row("Allgemein", 1, 2000, 1, 0.95)], }; return { id: "balance-1", projectId: "project-1", name: "Entwurfsplanung", workStage: "LPH 3", dateIso: "2023-06-30", reserveAvPercent: 10, reserveSvPercent: 0, sortOrder: 0, groups: estimateDefaultGroups.map((group, index) => ({ id: `group-${group.costGroup || "allg"}`, costGroup: group.costGroup, title: group.title, editor: null, sortOrder: index, rows: rowsByGroup[group.costGroup] ?? [], })), }; } function createTestDatabase(): DatabaseContext { const context = createDatabaseContext(":memory:"); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); context.db .insert(projects) .values([ { id: "project-1", name: "Test project" }, { id: "project-2", name: "Foreign project" }, ]) .run(); return context; } describe("estimate balance calculation", () => { it("reproduces the office template totals", () => { const result = calculateEstimateBalance(templateExample(), 400); const groupTotals = result.groups.map((group) => [ round2(group.av.kw), round2(group.av.kva), ]); assert.deepEqual(groupTotals, [ [35.52, 35.52], [3.04, 3.11], [6.4, 7.11], [3.2, 3.2], [40.18, 43.31], [12, 12.63], [11.2, 11.79], [29.7, 29.7], [0, 0], [2, 2.11], ]); assert.equal(round2(result.total.av.kw), 143.24); assert.equal(round2(result.total.av.kva), 148.48); assert.equal(round2(result.reserve.av.kw), 14.32); assert.equal(round2(result.reserve.av.kva), 14.85); assert.equal(round2(result.withReserve.av.kw), 157.56); assert.equal(round2(result.withReserve.av.kva), 163.33); assert.equal(round2(result.maxCurrentA.av), 235.75); assert.equal(result.total.sv.kw, 0); assert.equal(result.maxCurrentA.sv, 0); }); it("counts safety-supply rows in AV and additionally in SV, also per m²", () => { const area = calculateEstimateRow({ quantity: 250, powerPerUnitW: 12, simultaneityFactor: 0.8, cosPhi: 0.95, safetySupply: true, }); assert.equal(round2(area.av.kw), 2.4); assert.equal(round2(area.av.kva), 2.53); assert.deepEqual(area.sv, area.av); const balance = templateExample(); balance.reserveSvPercent = 20; balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale const result = calculateEstimateBalance(balance, 400); assert.equal(round2(result.total.sv.kw), 1.6); assert.equal(round2(result.withReserve.sv.kw), 1.92); assert.equal(round2(result.total.av.kw), 143.24); }); it("rounds displayed values like the spreadsheet", () => { assert.equal(roundEstimateValue(40.175), 40.18); assert.equal(roundEstimateValue(143.235), 143.24); assert.equal(roundEstimateValue(-1.825), -1.83); assert.equal(roundEstimateValue(0.711111), 0.71); assert.equal(roundEstimateValue(235.749, 1), 235.7); }); it("matches detailed cost groups to the most specific estimate group", () => { const codes = ["410", "430", "434", "440", "480", ""]; assert.equal(matchEstimateCostGroup("444", codes), "440"); assert.equal(matchEstimateCostGroup("434", codes), "434"); assert.equal(matchEstimateCostGroup("431", codes), "430"); assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480"); assert.equal(matchEstimateCostGroup("4", codes), null); assert.equal(matchEstimateCostGroup("", codes), null); assert.equal(matchEstimateCostGroup(null, codes), null); assert.equal(matchEstimateCostGroup("450", codes), null); }); it("compares the estimate with detailed device rows per cost group", () => { const comparison = compareEstimateWithDetailed(templateExample(), [ { costGroup: "444", kw: 30, cosPhi: 0.9 }, { costGroup: "445", kw: 12, cosPhi: null }, { costGroup: "412", kw: 40, cosPhi: 1 }, { costGroup: null, kw: 5, cosPhi: 1 }, { costGroup: "399", kw: 1, cosPhi: 1 }, ]); const kg440 = comparison.groups.find((group) => group.costGroup === "440")!; assert.equal(round2(kg440.detailed.kw), 42); assert.equal(round2(kg440.detailed.kva), 45.33); assert.equal(kg440.detailedRowCount, 2); assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9); assert.equal(round2(kg440.deltaPercent!), 4.54); const kg410 = comparison.groups.find((group) => group.costGroup === "410")!; assert.equal(round2(kg410.deltaKw), 4.48); const kg480 = comparison.groups.find((group) => group.costGroup === "480")!; assert.equal(kg480.deltaPercent, null); assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 }); assert.equal(round2(comparison.total.detailed.kw), 88); assert.equal(round2(comparison.total.estimatedAv.kw), 143.24); }); }); describe("estimate balance snapshots and commands", () => { it("validates snapshots strictly", () => { const valid = templateExample(); assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3); const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [ (balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), (balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), (balance) => (balance.groups[0]!.rows[0]!.quantity = -1), (balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), (balance) => (balance.groups[0]!.rows[0]!.supply = ""), (balance) => (balance.groups[0]!.title = ""), (balance) => (balance.dateIso = "30.06.2023"), (balance) => (balance.reserveAvPercent = 101), (balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), (balance) => ((balance.groups[0]!.rows[0] as unknown as Record).extra = 1), ]; for (const mutate of invalid) { const balance = structuredClone(valid); mutate(balance); assert.throws(() => parseEstimateBalanceSnapshot(balance)); } assert.equal( createEstimateBalanceRequestSchema.safeParse({ expectedRevision: 0, name: "Entwurf", template: "copy", }).success, false ); }); it("creates the KG 400 template and copies a Stand with new ids", () => { const created = createEstimateBalanceSnapshot({ projectId: "project-1", name: " Vorplanung ", workStage: "LPH 2", dateIso: "2026-09-26", sortOrder: 0, template: "kg400", }); assert.equal(created.name, "Vorplanung"); assert.deepEqual( created.groups.map((group) => group.costGroup), estimateDefaultGroups.map((group) => group.costGroup) ); assert.equal(created.reserveAvPercent, 10); const source = templateExample(); const copy = createEstimateBalanceSnapshot({ projectId: "project-1", name: "Entwurfsplanung Kopie", workStage: null, dateIso: null, sortOrder: 1, template: { copyFrom: source }, }); assert.notEqual(copy.id, source.id); assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id); assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id); assert.equal( round2(calculateEstimateBalance(copy, 400).total.av.kw), 143.24 ); }); it("inserts, updates and deletes balances with persisted undo and redo", () => { const context = createTestDatabase(); try { const store = new EstimateBalanceProjectCommandRepository(context.db); const reader = new EstimateBalanceRepository(context.db); const history = new ProjectHistoryRepository(context.db); let revision = 0; const run = ( command: EstimateBalanceProjectCommand, source: "user" | "undo" | "redo" = "user" ) => { const step = source === "user" ? null : history.getNextCommand("project-1", source); const result = store.execute({ projectId: "project-1", expectedRevision: revision, source, historyTargetChangeSetId: step?.changeSetId, command, }); revision += 1; return result; }; const balance = templateExample(); const insert = createEstimateBalanceInsertProjectCommand(balance); const inserted = run(insert); const stored = reader.findById("project-1", balance.id); assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance)); assert.equal(reader.listByProject("project-2").length, 0); const target = structuredClone(stored!); target.groups[0]!.rows.splice(1, 1); target.groups[0]!.rows[0]!.quantity = 10; target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95)); target.groups[8]!.editor = "MSR"; target.reserveAvPercent = 15; const update = createEstimateBalanceUpdateProjectCommand(stored!, target); const updated = run(update); assert.deepEqual( reader.findById("project-1", balance.id), parseEstimateBalanceSnapshot(target) ); run(updated.inverse, "undo"); assert.deepEqual(reader.findById("project-1", balance.id), stored); run(update, "redo"); assert.deepEqual( reader.findById("project-1", balance.id), parseEstimateBalanceSnapshot(target) ); // A stale expected document is rejected. assert.throws( () => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })), /changed before update/ ); assert.throws( () => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)), /must change/ ); const current = reader.findById("project-1", balance.id)!; const deleted = run(createEstimateBalanceDeleteProjectCommand(current)); assert.equal(reader.findById("project-1", balance.id), null); assert.equal(context.db.select().from(estimateRows).all().length, 0); run(deleted.inverse, "undo"); assert.deepEqual(reader.findById("project-1", balance.id), current); // The original insert's inverse no longer matches the edited balance. assert.throws(() => run(inserted.inverse), /changed before deletion/); run(createEstimateBalanceDeleteProjectCommand(current)); assert.equal(reader.listByProject("project-1").length, 0); assert.throws( () => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })), /does not belong to project/ ); } finally { context.close(); } }); it("sums detailed device rows of all circuit lists of the project", () => { const context = createTestDatabase(); try { context.db .insert(distributionBoards) .values({ id: "board-1", projectId: "project-1", name: "UV 1" }) .run(); context.db .insert(circuitLists) .values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" }) .run(); context.db .insert(circuitSections) .values({ id: "section-1", circuitListId: "list-1", key: "lighting-1", displayName: "Beleuchtung 1", prefix: "1F1", sortOrder: 0, }) .run(); context.db .insert(circuits) .values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-1F1", displayName: "Licht", sortOrder: 0, }) .run(); context.db .insert(circuitDeviceRows) .values([ { id: "row-1", circuitId: "circuit-1", name: "Leuchte", displayName: "Leuchte", costGroup: "445", quantity: 10, powerPerUnit: 0.05, simultaneityFactor: 0.8, cosPhi: 0.95, }, { id: "row-2", circuitId: "circuit-1", name: "Steckdose", displayName: "Steckdose", costGroup: null, quantity: 2, powerPerUnit: 0.2, simultaneityFactor: 0.5, cosPhi: null, }, ]) .run(); const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower( "project-1" ); assert.deepEqual( detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })), [ { costGroup: "445", kw: 0.4, cosPhi: 0.95 }, { costGroup: null, kw: 0.2, cosPhi: null }, ] ); assert.equal( new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length, 0 ); } finally { context.close(); } }); });