Assistent als Herzstueck: Tests und Spezifikation 0.36
Deploy App / deploy (push) Successful in 2m2s
Deploy App / deploy (push) Successful in 2m2s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user