Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:33:46 +02:00
parent c440063a93
commit 42d5585969
2 changed files with 311 additions and 104 deletions
+145
View File
@@ -0,0 +1,145 @@
// Der FPT-Assistent und die Element-Stammdaten (0.36).
//
// Der Assistent selbst ist UI; getestet wird hier die reine Logik dahinter -- der Fortschritt,
// der abgeleitete Stand und die Voraussetzungen je Schritt. Dazu die Eigenschaft, wegen der
// `baseData` überhaupt existiert: Ein Element muss sich erfassen lassen, BEVOR es Phasen gibt.
import { describe, it, expect } from "vitest";
import {
ASSISTANT_STEP_COUNT,
ASSISTANT_STEPS,
emptyProgress,
normalizeProgress,
stepBlockedReason,
stepStatus,
} from "@/lib/assistant";
import { computePlan } from "@/lib/calculations";
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,
planningHorizonYears: null,
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
phases: [],
elements: [],
...over,
};
}
function el(category: string, baseData: PhaseData = {}, phaseValues: Record<string, PhaseData> = {}) {
return {
id: `e-${category}-${Math.random().toString(36).slice(2, 7)}`,
category: category as never,
name: category,
ownerRole: "PERSON_A" as never,
orderIndex: 0,
phaseValues,
transitionValues: {},
baseData,
};
}
describe("Assistenten-Fortschritt", () => {
it("startet mit sieben offenen Schritten", () => {
expect(emptyProgress()).toHaveLength(ASSISTANT_STEP_COUNT);
expect(emptyProgress().some(Boolean)).toBe(false);
expect(ASSISTANT_STEPS).toHaveLength(ASSISTANT_STEP_COUNT);
});
it("verwirft kaputte Daten, statt daran zu scheitern", () => {
// Der Fortschritt liegt als JSON in der Datenbank -- ein alter oder manipulierter Stand
// darf die Ansicht nicht zerlegen.
expect(normalizeProgress(null)).toEqual(emptyProgress());
expect(normalizeProgress([true, false])).toEqual(emptyProgress()); // falsche Länge
expect(normalizeProgress("kaputt")).toEqual(emptyProgress());
const gut = [true, false, true, false, true, false, true];
expect(normalizeProgress(gut)).toEqual(gut);
});
});
describe("Abgeleiteter Stand neben dem Haken", () => {
// Der Haken ist bewusst manuell -- aber der Assistent soll nichts Falsches behaupten.
// Deshalb steht daneben, was tatsächlich da ist.
it("zählt die erfassten Elemente", () => {
expect(stepStatus(plan(), 0)).toBe("noch nichts erfasst");
expect(stepStatus(plan({ elements: [el("INCOME")] }), 0)).toBe("1 Element");
expect(stepStatus(plan({ elements: [el("INCOME"), el("EXPENSE")] }), 0)).toBe("2 Elemente");
});
it("meldet den fehlenden Planungshorizont", () => {
expect(stepStatus(plan(), 1)).toBe("Planungshorizont fehlt");
expect(stepStatus(plan({ planningHorizonYears: 40 }), 1)).toContain("40 Jahre");
});
it("zählt die Lebensphasen -- auch wenn es keine gibt", () => {
expect(stepStatus(plan(), 2)).toBe("0 Lebensphasen");
expect(
stepStatus(
plan({ phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }] }),
2
)
).toBe("1 Lebensphase");
});
});
describe("Voraussetzungen je Schritt", () => {
it("sperrt die Phasenplanung, solange der Horizont fehlt", () => {
expect(stepBlockedReason(plan(), 2)).toContain("Planungshorizont");
expect(stepBlockedReason(plan({ planningHorizonYears: 40 }), 2)).toBeNull();
});
it("sperrt die Übergangs-Schritte, solange es keine Phasen gibt", () => {
const p = plan({ planningHorizonYears: 40 });
for (const step of [3, 4, 5]) expect(stepBlockedReason(p, step)).toContain("Lebensphasen");
});
it("lässt die Bestandsaufnahme immer zu -- sie braucht keine Zeitachse", () => {
expect(stepBlockedReason(plan(), 0)).toBeNull();
expect(stepBlockedReason(plan(), 1)).toBeNull();
});
});
describe("Element-Stammdaten (baseData)", () => {
// Der Grund für den ganzen Umbau: Eine Bestandsaufnahme ist keine Aussage über eine Phase.
it("erlaubt Elemente ohne jede Lebensphase", () => {
const p = plan({ elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 3 })] });
const c = computePlan(p);
expect(c.phases).toHaveLength(0);
expect(c.ruinAge).toBeNull();
});
it("dient der ersten Phase als Startwert", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 0 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.startValue).toBe(100000);
});
it("ist die Wurzel der Vererbung -- Phase 1 erbt die Rendite von dort", () => {
// Vor 0.36 hatte Phase 1 nichts, von dem sie hätte erben können, und fiel auf 0.
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: {} })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(Math.round(100000 * Math.pow(1.05, 10)));
});
it("weicht einem erfassten Phasenwert -- der gewinnt", () => {
const p = plan({
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 10, cashTransition: {} }],
elements: [el("OTHER_ASSET", { startValue: 100000, expectedReturn: 5 }, { p1: { expectedReturn: 0 } })],
});
const asset = computePlan(p).phases[0].elements.find((e) => e.category === "OTHER_ASSET")!;
expect(asset.endValue).toBe(100000);
});
});