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[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) => 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); });