Finanzmanager/tests/13_VermoegensentwicklungTest.php
fds2610-bot 048bbf3894 Cockpit: Übrige Konten aufklappbar, Balken rücken auseinander
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:22:06 +02:00

313 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
use Finanzmanager\Models\Buchung;
use Finanzmanager\Models\Kategorie;
use Finanzmanager\Models\Konto;
use Finanzmanager\Services\Vermoegensentwicklung;
class VermoegensentwicklungTest extends TestCase
{
protected string $testName = 'Test 13: Vermögensentwicklung';
/** Ein fester Stichtag – sonst hinge das Ergebnis am Tag des Testlaufs. */
private const HEUTE = '2026-08-11';
private int $giro = 0;
private int $sparkonto = 0;
private int $kreditkarte = 0;
private int $auslagen = 0;
private int $kategorie = 0;
public function run(): void
{
$this->mandantAnlegen();
$this->stammdaten();
$this->zwoelfMonateBisHeute();
$this->staendeWachsenMonatFuerMonat();
$this->umbuchungVerschiebtZwischenKonten();
$this->summenkennzeichenFiltertNicht();
$this->schalterFiltertStillgelegteKonten();
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
$this->ebenenBlaetternDurchDieUebrigenKonten();
$this->skalaRundetAufLesbareSchritte();
}
private function stammdaten(): void
{
$this->giro = Konto::anlegen([
'name' => 'Vermögensgiro', 'kontoart' => 'bank', 'anfangssaldo' => 1000, 'aktiv' => 1,
]);
$this->sparkonto = Konto::anlegen([
'name' => 'Vermögenssparbuch', 'kontoart' => 'sparkonto', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->kreditkarte = Konto::anlegen([
'name' => 'Vermögenskarte', 'kontoart' => 'kreditkarte', 'anfangssaldo' => -200, 'aktiv' => 1,
]);
// Durchlaufende Auslagen sind kein Vermögen und tragen kein Σ-Kennzeichen.
$this->auslagen = Konto::anlegen([
'name' => 'Vermögensauslagen', 'kontoart' => 'sonstiges', 'anfangssaldo' => 5000,
'aktiv' => 1, 'in_summe' => 0,
]);
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
}
private function reihe(bool $nurAktive = true, int $ebene = 0): array
{
return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene);
}
/** @return array<string,mixed>|null */
private function konto(array $reihe, int $id): ?array
{
foreach ($reihe['konten'] as $konto) {
if ($konto['id'] === $id) {
return $konto;
}
}
return null;
}
private function zwoelfMonateBisHeute(): void
{
$monate = Vermoegensentwicklung::monate(12, self::HEUTE);
$this->assertEquals(12, count($monate), 'Zwölf Monate');
$this->assertEquals(2025, $monate[0]['jahr'], 'Der Rückblick beginnt im Vorjahr');
$this->assertEquals(9, $monate[0]['monat'], 'Zwölf Monate vor August 2026 ist September 2025');
$this->assertEquals(2026, $monate[11]['jahr'], 'Der letzte Balken ist der laufende Monat');
$this->assertEquals(8, $monate[11]['monat'], 'August 2026');
$this->assertEquals('2026-08-31', $monate[11]['bis'], 'Stichtag ist das Monatsende');
$this->assertEquals('2026-02-28', $monate[5]['bis'], 'Auch im Februar das echte Monatsende');
$this->assertEquals('Sep', $monate[0]['kuerzel'], 'Monatskürzel für die Achse');
}
/**
* Der Stand eines Monats ist der Stand des Vormonats plus dessen Bewegungen –
* und Buchungen vor dem Zeitraum stecken im Anfangsstand des ersten Balkens.
*/
private function staendeWachsenMonatFuerMonat(): void
{
// Lange vor dem Zeitraum: zählt in den ersten Balken hinein.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2024-05-10', 'betrag' => 500,
'empfaenger' => 'Altbestand', 'kategorie_id' => $this->kategorie,
]);
// Im Zeitraum: hebt den Stand ab Dezember 2025.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2025-12-05', 'betrag' => 300,
'empfaenger' => 'Zufluss', 'kategorie_id' => $this->kategorie,
]);
// Nach dem letzten Balken: bleibt außen vor.
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-11-01', 'betrag' => 9999,
'empfaenger' => 'Zukunft', 'kategorie_id' => $this->kategorie,
]);
$reihe = $this->reihe();
$giro = $this->konto($reihe, $this->giro);
$this->assertNotEmpty($giro, 'Das Girokonto hat einen Balken');
$this->assertEquals(1500.0, $giro['werte'][0], 'September 2025: Anfangssaldo plus Altbestand');
$this->assertEquals(1500.0, $giro['werte'][2], 'November 2025 unverändert');
$this->assertEquals(1800.0, $giro['werte'][3], 'Dezember 2025 mit dem Zufluss');
$this->assertEquals(1800.0, $giro['werte'][11], 'Der Stand trägt bis in den letzten Monat');
// Die Kreditkarte steht im Minus – sie zieht die Summe herunter.
$karte = $this->konto($reihe, $this->kreditkarte);
$this->assertEquals(-200.0, $karte['werte'][11], 'Negativer Stand bleibt negativ');
$this->assert($reihe['hatWerte'], 'Es gibt etwas zu zeichnen');
// Giro 1800, Kreditkarte -200, Auslagen 5000, Sparbuch 0.
$this->assertEquals(6600.0, $reihe['summen'][11], 'Die Summe ist der Stapel aller Konten');
}
private function umbuchungVerschiebtZwischenKonten(): void
{
Buchung::anlegen([
'konto_id' => $this->giro, 'datum' => '2026-03-15', 'betrag' => -400,
'empfaenger' => 'Umbuchung aufs Sparbuch', 'gegenkonto_id' => $this->sparkonto,
]);
$reihe = $this->reihe();
$giro = $this->konto($reihe, $this->giro);
$spar = $this->konto($reihe, $this->sparkonto);
$this->assertEquals(1800.0, $giro['werte'][5], 'Februar 2026 noch ohne Umbuchung');
$this->assertEquals(1400.0, $giro['werte'][6], 'März 2026: 400 weniger auf dem Giro');
$this->assertEquals(400.0, $spar['werte'][6], 'Dieselben 400 liegen auf dem Sparbuch');
$this->assertEquals(
$reihe['summen'][5],
$reihe['summen'][6],
'Eine Umbuchung ändert das Vermögen nicht'
);
}
/**
* Das Σ-Kennzeichen entscheidet hier nichts: Stillgelegte Konten tragen es
* meist nicht, und dann fiele beim Ausschalten des Schalters genau das weg,
* was man sehen will.
*/
private function summenkennzeichenFiltertNicht(): void
{
$reihe = $this->reihe();
$this->assertNotEmpty(
$this->konto($reihe, $this->auslagen),
'Ein aktives Konto ohne Σ-Kennzeichen zeichnet trotzdem einen Balken'
);
// Ein Konto, das im ganzen Zeitraum auf null steht, bläht nur die Legende auf.
$leer = Konto::anlegen([
'name' => 'Vermögensleerkonto', 'kontoart' => 'bank', 'anfangssaldo' => 0, 'aktiv' => 1,
]);
$this->assertNull($this->konto($this->reihe(), $leer), 'Ein durchgehend leeres Konto fehlt');
}
/**
* Der Schalter „nur aktive“ an der Kachel: eingeschaltet zählt nur, was noch
* geführt wird; ausgeschaltet auch das Vermögen auf stillgelegten Konten.
*/
private function schalterFiltertStillgelegteKonten(): void
{
$stillgelegt = Konto::anlegen([
'name' => 'Vermögensaltkonto', 'kontoart' => 'bank', 'anfangssaldo' => 2500, 'aktiv' => 0,
]);
$this->assertNull(
$this->konto($this->reihe(), $stillgelegt),
'Eingeschaltet bleibt das stillgelegte Konto draußen'
);
$alle = $this->reihe(false);
$this->assertNotEmpty($this->konto($alle, $stillgelegt), 'Ausgeschaltet ist es dabei');
$this->assertEquals(
2500.0,
$this->konto($alle, $stillgelegt)['werte'][11],
'Mit seinem Stand'
);
$this->assertEquals(
round($this->reihe()['summen'][11] + 2500.0, 2),
$alle['summen'][11],
'Und die Summe wächst um genau diesen Stand'
);
}
/**
* Zwölf Farbtöne hat die Palette; darüber hinaus behalten die größten Konten
* ihre Farbe, der Rest wird zu einem Stück zusammengefasst.
*/
private function mehrKontenAlsFarbenWerdenZusammengefasst(): void
{
for ($nummer = 1; $nummer <= 15; $nummer++) {
Konto::anlegen([
'name' => 'Vermögensklein ' . $nummer,
'kontoart' => 'bank',
'anfangssaldo' => $nummer,
'aktiv' => 1,
]);
}
$reihe = $this->reihe();
$konten = $reihe['konten'];
$letzte = $konten[count($konten) - 1];
$this->assertEquals(
Vermoegensentwicklung::SERIEN,
count($konten),
'Nicht mehr Balkenstücke als Farbtöne'
);
$this->assertEquals(0, $letzte['id'], 'Das letzte Stück steht für kein einzelnes Konto');
$this->assert(str_starts_with($letzte['name'], 'Übrige Konten ('), 'Es heißt „Übrige Konten“');
// Die größten behalten ihre eigene Farbe – das Sparbuch ist dabei.
$this->assertNotEmpty($this->konto($reihe, $this->sparkonto), 'Ein großes Konto bleibt eigenständig');
// Zusammengefasst wird, nicht weggelassen: Die Summe bleibt dieselbe.
$gestapelt = 0.0;
foreach ($konten as $konto) {
$gestapelt += $konto['werte'][11];
}
$this->assertEquals(
$reihe['summen'][11],
round($gestapelt, 2),
'Die Balkenstücke ergeben zusammen die Summe'
);
}
/**
* „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren
* Konten einzeln, bis der Rest in die Palette passt.
*/
private function ebenenBlaetternDurchDieUebrigenKonten(): void
{
$this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene');
$this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf');
$this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen');
$this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte');
// Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat.
$erste = $this->reihe(true, 0);
$zweite = $this->reihe(true, 1);
$this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene');
$this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene');
$this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar');
$ids = static fn(array $reihe): array => array_map(
static fn(array $konto): int => $konto['id'],
$reihe['konten']
);
$this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein');
$this->assertEquals(
[],
array_intersect(array_diff($ids($erste), [0]), $ids($zweite)),
'Kein Konto steht auf zwei Ebenen'
);
// Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an.
$letzte = $this->reihe(true, $erste['ebenen'] - 1);
$this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten');
// Zusammen ergeben alle Ebenen wieder den ganzen Bestand.
$gesehen = [];
for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) {
$gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0]));
}
$this->assertEquals(
count($gesehen),
count(array_unique($gesehen)),
'Über alle Ebenen hinweg taucht kein Konto doppelt auf'
);
$this->assertGreaterThan(
Vermoegensentwicklung::SERIEN,
count($gesehen),
'Und in Summe sind es mehr Konten, als eine Ebene fasst'
);
// Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück.
$zuTief = $this->reihe(true, 99);
$this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten');
$this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten');
}
private function skalaRundetAufLesbareSchritte(): void
{
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
$this->assertEquals(0.0, $skala['min'], 'Die Null gehört immer dazu');
$this->assertGreaterThan(4869.0, $skala['max'], 'Der größte Wert passt hinein');
$this->assertEquals(0.0, fmod($skala['max'], $skala['schritt']), 'Das Maximum liegt auf einem Schritt');
$mitMinus = Vermoegensentwicklung::skala([-820.0, 2500.0]);
$this->assert($mitMinus['min'] <= -820.0, 'Auch der negative Ausschlag passt hinein');
$this->assert($mitMinus['max'] >= 2500.0, 'Und der positive');
$leer = Vermoegensentwicklung::skala([]);
$this->assertGreaterThan(0.0, $leer['schritt'], 'Ohne Werte bleibt die Skala brauchbar');
}
}