Uebersicht der offenen Punkte statt Assistent, Bestaetigung je Phasenzelle
Deploy App / deploy (push) Successful in 1m55s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:02:15 +02:00
parent 6ff144d7e1
commit f22b0a3f27
22 changed files with 710 additions and 1006 deletions
+141
View File
@@ -0,0 +1,141 @@
// Die Übersicht der offenen Punkte (0.39).
//
// Sie ersetzt den FPT-Assistenten. Getestet wird die Regel dahinter: Wann gilt etwas als
// offen, wann als erledigt -- und die beiden Sonderzustände am Anfang, in denen eine Zahl
// eine Lüge wäre.
import { describe, it, expect } from "vitest";
import { computePlan } from "@/lib/calculations";
import { reviewPlan, unconfirmedCells, isCellConfirmed } from "@/lib/review";
import type { PhaseData } from "@/lib/elements";
import type { PlanInput } from "@/lib/types";
function plan(over: Partial<PlanInput> = {}): PlanInput {
return {
id: "plan",
name: "T",
householdType: "SINGLE",
inflationRateDefault: 0,
initialCash: 0,
startYear: 2026,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function phase(id: string, seq: number, years: number, ratesConfirmed = true) {
return { id, sequenceNumber: seq, name: id, durationYears: years, cashTransition: { mode: "NONE" as const }, ratesConfirmed };
}
let n = 0;
function el(category: string, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e${n++}`,
category: category as never,
name: category,
ownerRole: "HOUSEHOLD" as never,
orderIndex: n,
phaseValues,
transitionValues: {},
baseData: {},
};
}
const ok = { confirmed: true };
describe("Die beiden Startzustände", () => {
// Ohne Elemente und ohne Phasen gibt es naturgemäss nichts Offenes -- der Plan ist trotzdem
// leer. Eine «0» wäre dort eine Lüge, deshalb eigene Zustände.
it("meldet einen Plan ohne Elemente als NO_ELEMENTS, nicht als fertig", () => {
const p = plan();
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("NO_ELEMENTS");
expect(r.total).toBe(0);
});
it("meldet Elemente ohne Lebensphasen als NO_PHASES", () => {
const p = plan({ elements: [el("OTHER_ASSET")] });
expect(reviewPlan(p, computePlan(p)).stage).toBe("NO_PHASES");
});
});
describe("Offene Punkte je Phase", () => {
it("zählt jede unbestätigte Zelle", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: {} }), el("EXPENSE", { p1: {} }), el("OTHER_ASSET", { p1: ok })],
});
const c = computePlan(p);
expect(unconfirmedCells(p, c, "p1")).toHaveLength(2);
const r = reviewPlan(p, c);
expect(r.stage).toBe("OPEN");
expect(r.total).toBe(2);
expect(r.groups[0].reasons[0]).toContain("2 Annahmen");
});
it("zählt die nicht verteilte Sparquote eigens", () => {
// Man kann jede Zelle angeschaut und die Quote trotzdem nie verteilt haben -- dann bliebe
// alles still auf dem Cash-Konto.
const p = plan({
phases: [phase("p1", 1, 10, false)],
elements: [el("INCOME", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.total).toBe(1);
expect(r.groups[0].reasons[0]).toContain("Sparquote nicht verteilt");
});
it("ist fertig, wenn alles bestätigt und verteilt ist", () => {
const p = plan({
phases: [phase("p1", 1, 10)],
elements: [el("INCOME", { p1: ok }), el("EXPENSE", { p1: ok })],
});
const r = reviewPlan(p, computePlan(p));
expect(r.stage).toBe("DONE");
expect(r.total).toBe(0);
expect(r.groups).toHaveLength(0);
});
it("verlangt keine Bestätigung für ein verkauftes Element", () => {
// Ein verkauftes Haus trägt in der Folgephase keine Annahmen mehr.
const p = plan({
phases: [phase("p1", 1, 5), phase("p2", 2, 5)],
elements: [
el("INCOME", { p1: ok, p2: ok }),
{
...el("REAL_ESTATE", { p1: { ...ok, purchasePrice: 800000, mortgage: 500000 }, p2: {} }),
transitionValues: { p1: { decision: "SELL" as const, salePrice: 900000 } },
},
],
});
const c = computePlan(p);
// In p2 ist die Immobilie verkauft -- nur das Einkommen zählt, und das ist bestätigt.
expect(unconfirmedCells(p, c, "p2")).toHaveLength(0);
});
});
describe("Bestätigen friert nichts ein", () => {
// Der wichtigste Punkt: Der Haken steht NEBEN den Werten. Eine bestätigte Phase erbt
// weiterhin live aus der Vorphase -- sonst wäre die Feld-Vererbung hinfällig.
it("lässt die Vererbung unberührt", () => {
const p = plan({
phases: [phase("p1", 1, 10), phase("p2", 2, 10)],
elements: [
{
...el("OTHER_ASSET"),
baseData: { startValue: 100000, expectedReturn: 5 },
// p2 ist bestätigt, hat aber KEINEN eigenen Renditewert -- sie muss erben.
phaseValues: { p1: ok, p2: ok },
},
],
});
const c = computePlan(p);
const p2 = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
// 100'000 über 20 Jahre zu 5 % -- die Rendite gilt in beiden Phasen. Auf den Franken
// genau geht es nicht: Der Rechenkern rundet an jeder Phasengrenze.
expect(p2.endValue).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
expect(isCellConfirmed(p, "p2", p.elements[0].id)).toBe(true);
});
});