Files
FPT/src/lib/report.test.ts
T
admGitAICDS e22f2cfa2b
Deploy App / deploy (push) Successful in 1m10s
Fix: PDF-Bericht -- CJS-Interop, Leerseiten und sichtbare Fehler
1) pdfkit wird als externes CJS-Modul geladen. Je nach Interop kommt der
Konstruktor direkt oder unter .default an -- trifft man die falsche Form,
gelingt der Import, aber `new PDFDocument()` scheitert erst zur Laufzeit
(passt zum gemeldeten 500: unauth. Aufruf gab 401, die Erzeugung 500).
Beide Formen werden jetzt akzeptiert.

2) Die Fusszeile stand unterhalb des Satzspiegels -- pdfkit haengt dafuer
automatisch Seiten an. Ein Bericht mit 6 Inhaltsseiten wurde so auf 18
aufgeblaeht. Unterer Rand wird fuers Schreiben auf 0 gesetzt; ein Test
prueft jetzt die Seitenzahl im FERTIGEN PDF, nicht davor.

3) Rendern und Speichern melden ihre Ursache statt eines nackten 500.

Spezifikation unveraendert, 1 Test ergaenzt (221 -> 222).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:15:01 +02:00

208 lines
8.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { buildReport, MAX_REPORT_SCENARIOS } from "@/lib/report";
import { renderReportPdf } from "@/lib/report-pdf";
import type { ActualsSetInput } from "@/lib/actuals";
import type { PlanInput } from "@/lib/types";
// Paar, 45/43, 20 Jahre Erwerb + 20 Jahre Pension, PK + ETF-Depot.
function plan(name: string, ret = 4): PlanInput {
return {
id: "s1",
name,
householdType: "COUPLE",
inflationRateDefault: 1.5,
initialCash: 20000,
startYear: 2026,
persons: [
{ id: "A", role: "PERSON_A", name: "Anna", age: 45, retirementAge: 65 },
{ id: "B", role: "PERSON_B", name: "Beat", age: 43, retirementAge: 64 },
],
phases: [
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 20, cashTransition: {} },
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 20, cashTransition: {} },
],
elements: [
{
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
phaseValues: { p1: { amount: 140000, teuerungsausgleich: 1 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
phaseValues: { p1: { amount: 90000 }, p2: { amount: 80000 } }, transitionValues: {}, sourceElementId: null,
},
{
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
phaseValues: { p1: { currentValue: 320000, expectedReturn: 2, annualContribution: 12000 } },
transitionValues: {}, sourceElementId: null,
},
{
id: "etf", category: "OTHER_ASSET", name: "ETF-Depot", ownerRole: "HOUSEHOLD", orderIndex: 4,
phaseValues: {
p1: { startValue: 180000, expectedReturn: ret, annualContribution: 9000 },
p2: { expectedReturn: ret, annualWithdrawal: 24000 },
},
transitionValues: {}, sourceElementId: null,
},
],
} as unknown as PlanInput;
}
function input(over: Partial<Parameters<typeof buildReport>[0]> = {}) {
return {
planName: "Meine Planung",
author: "kelle",
createdAt: new Date("2026-07-21T10:00:00Z"),
config: {
title: "Finanzplanung 2026",
metric: "nominal" as const,
source: "PLAN" as const,
scenarioIds: ["s1"],
analysisIds: [] as string[],
comment: null,
},
scenarios: [{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") }],
household: {
householdType: "COUPLE" as const,
startYear: 2026,
persons: [
{ name: "Anna", age: 45, role: "PERSON_A" },
{ name: "Beat", age: 43, role: "PERSON_B" },
],
},
actuals: [] as ActualsSetInput[],
origins: [] as { id: string; sourceElementId?: string | null }[],
analyses: [] as { name: string; type: string; result: unknown }[],
...over,
};
}
describe("buildReport", () => {
it("stellt die Zusammenfassung aus dem Basisszenario zusammen", () => {
const m = buildReport(input());
expect(m.summary.figures.length).toBeGreaterThan(3);
// Die drei Kernaussagen: Endvermögen, Reichweite, offene Entscheide.
expect(m.summary.statements).toHaveLength(3);
expect(m.summary.statements[0]).toContain("Endvermögen");
});
it("nennt zu JEDER Kennzahl die zugrunde liegende Annahme", () => {
// Der Kern der Anforderung: kein Ergebnis ohne seine Grundlage.
const m = buildReport(input());
for (const f of m.scenarios[0].keyFigures) {
expect(f.basis, `Kennzahl «${f.label}» ohne Basis-Angabe`).toBeTruthy();
}
});
it("führt die Annahmen zentral je Szenario -- mit den echten Startwerten", () => {
const m = buildReport(input());
const flat = m.scenarios[0].assumptions.flatMap((g) => g.rows.map(([k, v]) => `${k}: ${v}`)).join(" | ");
expect(flat).toContain("Inflation");
expect(flat).toContain("Pensionsalter Anna");
// Startwert und Rendite des Depots müssen ablesbar sein.
expect(flat).toMatch(/ETF-Depot.*Start.*Rendite 4 %/);
});
it("deckelt die Szenarien und vergleicht erst ab zwei", () => {
const one = buildReport(input());
expect(one.comparison).toBeUndefined();
const many = buildReport(
input({
scenarios: [
{ name: "Basis", isBase: true, plan: plan("Basis") },
{ name: "B", isBase: false, plan: plan("B", 3) },
{ name: "C", isBase: false, plan: plan("C", 2) },
{ name: "D", isBase: false, plan: plan("D", 1) },
],
})
);
expect(many.scenarios).toHaveLength(MAX_REPORT_SCENARIOS);
expect(many.comparison!.rows).toHaveLength(MAX_REPORT_SCENARIOS);
});
it("zeigt den Plan/Ist-Block nur, wenn effektive Werte gewählt UND vorhanden sind", () => {
const sets: ActualsSetInput[] = [
{ id: "a1", recordedOn: "2030-08-18", year: 2030, cash: null, values: { etf: { value: 400000 } } },
];
const p = plan("Basisszenario");
const origins = p.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }));
// Gewählt, aber keine Sätze -> kein Block.
expect(buildReport(input({ config: { ...input().config, source: "ACTUAL" } })).actuals).toBeUndefined();
// Sätze vorhanden, aber Plandaten gewählt -> kein Block.
expect(buildReport(input({ actuals: sets, origins })).actuals).toBeUndefined();
// Beides -> Block mit Abweichung.
const m = buildReport(input({ config: { ...input().config, source: "ACTUAL" }, actuals: sets, origins }));
expect(m.actuals).toBeDefined();
expect(m.actuals!.rows.map(([k]) => k)).toContain("Abweichung");
});
it("rechnet real, wenn real gewählt ist", () => {
const nominal = buildReport(input());
const real = buildReport(input({ config: { ...input().config, metric: "real" } }));
const val = (m: ReturnType<typeof buildReport>) =>
m.scenarios[0].keyFigures.find((f) => f.label.startsWith("Endvermögen"))!.value;
// Bei positiver Inflation liegt der Realwert unter dem nominalen.
expect(val(real)).not.toBe(val(nominal));
expect(real.meta.metricLabel).toContain("real");
});
it("trägt immer einen Haftungsausschluss", () => {
// Ein formal aussehendes PDF wird als Beratung gelesen -- das muss dagegenstehen.
const m = buildReport(input());
expect(m.disclaimer.length).toBeGreaterThan(2);
expect(m.disclaimer.join(" ")).toContain("keine Anlage-");
});
});
describe("renderReportPdf", () => {
it("erzeugt eine gültige, vollständige PDF-Datei", async () => {
const m = buildReport(
input({
scenarios: [
{ name: "Basisszenario", isBase: true, plan: plan("Basisszenario") },
{ name: "Tiefere Rendite", isBase: false, plan: plan("Tiefere Rendite", 2) },
],
analyses: [
{
name: "Monte-Carlo Basis",
type: "MONTE_CARLO",
result: {
params: [{ label: "Zielbetrag", value: "3'000'000" }],
table: { columns: ["Szenario", "Realismus"], rows: [["Basis", "63 %"]] },
},
},
],
})
);
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
// Ohne sauberes Dateiende lässt sich das PDF nicht öffnen.
expect(pdf.subarray(-1024).toString("latin1")).toContain("%%EOF");
expect(pdf.length).toBeGreaterThan(5000);
}, 30000);
it("hängt keine leeren Seiten an", async () => {
// Die Fusszeile steht unterhalb des Satzspiegels; ohne Vorkehrung fügt pdfkit dafür je
// Seite neue Seiten an und der Bericht füllt sich mit Leerseiten.
const m = buildReport(input());
const pdf = await renderReportPdf(m);
const s = pdf.toString("latin1");
const pages = (s.match(/\/Type\s*\/Page[^s]/g) ?? []).length;
// Ein Einzelszenario ergibt Deckblatt + Szenario + Hinweise.
expect(pages).toBeGreaterThanOrEqual(3);
expect(pages).toBeLessThanOrEqual(5);
}, 30000);
it("kommt auch mit einem leeren Plan zurecht, statt zu werfen", async () => {
// Robustheit: Ein Plan ohne Phasen darf keinen Absturz erzeugen.
const leer = { ...plan("Leer"), phases: [], elements: [] } as unknown as PlanInput;
const m = buildReport(input({ scenarios: [{ name: "Leer", isBase: true, plan: leer }] }));
const pdf = await renderReportPdf(m);
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
}, 30000);
});