leistungsbilanz-ts/tests/estimate-balance.test.ts
Julian Appel a8cc7eca5e Tighten rejection tests to their expected errors
Every assert.throws now names the error it expects, so a broken fixture
or an unrelated failure can no longer satisfy a rejection test. Zod
rejections are matched by issue path and code via tests/support/zod-assertions.ts.

Restore snapshot coverage lost with the release baseline: invalid board
floor, room floor and circuit section references and a reserve state
that contradicts the device rows are rejected again under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:27:46 +01:00

462 lines
17 KiB
TypeScript

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";
import { zodIssues, type ExpectedZodIssue } from "./support/zod-assertions.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> = {}
): EstimateRowSnapshot {
return {
id: id(),
sortOrder: 0,
supply: "ELT",
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<string, EstimateRowSnapshot[]> = {
"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, ExpectedZodIssue]> = [
[(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), ["groups.0.rows.0.cosPhi", "too_small"]],
[(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), ["groups.0.rows.0.simultaneityFactor", "too_big"]],
[(balance) => (balance.groups[0]!.rows[0]!.quantity = -1), ["groups.0.rows.0.quantity", "too_small"]],
[(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), ["groups.0.rows.0.name", "custom"]],
[(balance) => (balance.groups[0]!.rows[0]!.supply = ""), ["groups.0.rows.0.supply", "custom"]],
[(balance) => (balance.groups[0]!.title = ""), ["groups.0.title", "custom"]],
[(balance) => (balance.dateIso = "30.06.2023"), ["dateIso", "invalid_format"]],
[(balance) => (balance.reserveAvPercent = 101), ["reserveAvPercent", "too_big"]],
[(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), ["groups", "custom"]],
[
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
["groups.0.rows.0", "unrecognized_keys", "extra"],
],
];
for (const [mutate, expectedIssue] of invalid) {
const balance = structuredClone(valid);
mutate(balance);
assert.throws(() => parseEstimateBalanceSnapshot(balance), zodIssues(expectedIssue));
}
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();
}
});
});