ce987b9978
Deploy App / deploy (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
// 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 eine Zelle ohne Annahmen", () => {
|
|
// Seit 0.40 stehen Bestände in den Stammdaten und Raten im Verteil-Dialog. Eine
|
|
// «Sonstige Schuld» trägt in der Phasenzelle damit gar nichts mehr -- eine Bestätigung
|
|
// wäre eine Frage ohne Gegenstand.
|
|
const p = plan({
|
|
phases: [phase("p1", 1, 10)],
|
|
elements: [el("OTHER_DEBT", { p1: {} })],
|
|
});
|
|
const c = computePlan(p);
|
|
expect(unconfirmedCells(p, c, "p1")).toHaveLength(0);
|
|
expect(reviewPlan(p, c).stage).toBe("DONE");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|