e22f2cfa2b
Deploy App / deploy (push) Successful in 1m10s
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>
208 lines
8.3 KiB
TypeScript
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);
|
|
});
|