OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

MoneyTest.php

13 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\Money;

/**
 * Deckt Anzeige und Rücklesen von Geldbeträgen ab.
 *
 * Der heikle Teil ist die Eingabe: Im deutschen Format trennt der Punkt die
 * Tausender, in Preisfeldern tippen Leute aber auch `1234.56` und meinen Cent.
 * Wer das verwechselt, verschiebt Beträge um den Faktor hundert – deshalb ist
 * hier jeder Grenzfall festgehalten.
 */
return static function (): void {

    $waehrung = static fn(array $werte = []): Money => new Money($werte);

    test('Jede Vorlage bringt Symbol, Stellung und Zahlenformat mit', function () {
        foreach (array_keys(Money::VORLAGEN) as $code) {
            [$symbol, $stellung, $dezimal, $tausender] = Money::vorlage($code);
            assertTrue($symbol !== '', 'Ohne Symbol: ' . $code);
            assertTrue(in_array($stellung, Money::STELLUNGEN, true), 'Unbekannte Stellung bei ' . $code);
            assertTrue($dezimal !== '', 'Ohne Dezimaltrenner: ' . $code);
            assertTrue($dezimal !== $tausender, 'Gleiche Trenner bei ' . $code);
        }
    });

    test('Ohne Angabe gilt Euro', function () use ($waehrung) {
        assertSame('EUR', $waehrung(['currency_code' => ''])->code());
        assertSame('EUR', $waehrung([])->code());
        // Was keine drei Buchstaben sind, ist kein Währungscode.
        assertSame('EUR', $waehrung(['currency_code' => 'Euro!'])->code());
    });

    test('Eine Waehrung ohne Vorlage traegt ihren Code als Symbol', function () use ($waehrung) {
        // Sonst stuende an einem ungarischen Betrag ein Eurozeichen.
        $m = $waehrung(['currency_code' => 'HUF']);
        assertSame('HUF', $m->code());
        assertSame('HUF', $m->symbol());
        assertFalse(str_contains($m->format(1234.5), '€'), $m->format(1234.5));
        assertTrue(str_starts_with($m->format(1234.5), 'HUF'), $m->format(1234.5));
    });

    test('Der Betrag erscheint im Format der Waehrung', function () use ($waehrung) {
        // Das Trennzeichen zwischen Zahl und Symbol ist ein schmales
        // geschütztes Leerzeichen; es darf nicht umbrechen.
        $eur = $waehrung(['currency_code' => 'EUR'])->format(1234.5);
        assertTrue(str_contains($eur, '1.234,50'), 'Deutsches Zahlenformat fehlt: ' . $eur);
        assertTrue(str_ends_with($eur, '€'), 'Symbol steht nicht hinten: ' . $eur);

        $usd = $waehrung(['currency_code' => 'USD'])->format(1234.5);
        assertTrue(str_contains($usd, '1,234.50'), 'Englisches Zahlenformat fehlt: ' . $usd);
        assertTrue(str_starts_with($usd, '$'), 'Symbol steht nicht vorn: ' . $usd);
    });

    test('Ohne Symbol bleibt die reine Zahl', function () use ($waehrung) {
        assertSame('1.234,50', $waehrung(['currency_code' => 'EUR'])->number(1234.5));
        assertSame('1,234.50', $waehrung(['currency_code' => 'USD'])->number(1234.5));
        assertSame('1.235',    $waehrung(['currency_code' => 'EUR'])->number(1234.5, 0));
    });

    test('Symbol, Stellung und Trenner lassen sich einzeln setzen', function () use ($waehrung) {
        $m = $waehrung([
            'currency_code'     => 'USD',
            'currency_symbol'   => 'US$',
            'currency_position' => 'nach',
        ]);
        assertTrue(str_ends_with($m->format(9.5), 'US$'), $m->format(9.5));

        $m2 = $waehrung(['currency_code' => 'EUR', 'number_decimal' => '.', 'number_thousand' => ',']);
        assertSame('1,234.50', $m2->number(1234.5));
    });

    test('Gleiche Trenner ergeben keine unlesbare Zahl', function () use ($waehrung) {
        // Sonst stuende dort 1.234.56.
        $m = $waehrung(['currency_code' => 'EUR', 'number_decimal' => '.', 'number_thousand' => '.']);
        assertSame('1234.50', $m->number(1234.5));
    });

    test('Das eingestellte Format wird zurueckgelesen', function () use ($waehrung) {
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(1234.56, $m->parse('1.234,56'));
        assertSame(1234567.89, $m->parse('1.234.567,89'));
        assertSame(-45.9, $m->parse('-45,90'));
        assertSame(0.5, $m->parse('0,5'));
    });

    test('Eine Tausendergruppe bleibt eine Tausendergruppe', function () use ($waehrung) {
        // Genau drei Ziffern hinter dem Punkt: zwölftausend, nicht zwölf.
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(12000.0, $m->parse('12.000'));
        assertSame(1234.0,  $m->parse('1.234'));
    });

    test('Nachkommastellen mit Punkt werden verstanden', function () use ($waehrung) {
        // Wer `1234.56` tippt, meint Cent – nicht hundertdreiundzwanzigtausend.
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(1234.56, $m->parse('1234.56'));
        assertSame(99.999,  $m->parse('99,999'));
        assertSame(0.5,     $m->parse('.5'));
    });

    test('Symbol und Leerzeichen stoeren die Eingabe nicht', function () use ($waehrung) {
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(1234.56, $m->parse(' 1.234,56 € '));
        assertSame(1234.56, $m->parse("1.234,56\u{202F}€"));
        assertSame(1234.56, $m->parse('EUR 1.234,56'));
    });

    test('Die Gegenprobe gilt fuer jede Vorlage', function () use ($waehrung) {
        foreach (array_keys(Money::VORLAGEN) as $code) {
            $m = $waehrung(['currency_code' => $code]);
            $zurueck = $m->parse($m->format(1234.56));
            assertTrue($zurueck !== null && abs($zurueck - 1234.56) < 0.001,
                $code . ': ' . $m->format(1234.56) . ' ergibt ' . var_export($zurueck, true));
        }
    });

    test('Unbrauchbare Eingaben ergeben nichts', function () use ($waehrung) {
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(null, $m->parse('abc'));
        assertSame(null, $m->parse(''));
        assertSame(null, $m->parse('   '));
        assertSame(null, $m->parse(null));
    });

    test('Zahlen bleiben Zahlen', function () use ($waehrung) {
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame(12.5, $m->parse(12.5));
        assertSame(12.0, $m->parse(12));
    });

    test('Die Tabellenkalkulation bekommt eine nackte Zahl', function () use ($waehrung) {
        // Ohne Tausendertrenner und ohne Symbol liest jede Tabellenkalkulation
        // den Wert als Zahl ein; die Währung steht in der Spaltenüberschrift.
        $m = $waehrung(['currency_code' => 'EUR']);
        assertSame('1234,56', $m->csv(1234.56));
        assertSame('0,00', $m->csv(null));
        assertSame('12,5', $m->csv(12.5, 1));
        assertSame('-99,90', $m->csv(-99.9));

        $chf = $waehrung(['currency_code' => 'CHF']);
        assertSame('1234.56', $chf->csv(1234.56));
    });

    test('Die Druckausgabe bleibt maskiert und umbruchfest', function () use ($waehrung) {
        $m = $waehrung(['currency_code' => 'EUR']);
        // Schmales Leerzeichen nur am Bildschirm; im PDF das geschützte,
        // das jede mitgelieferte Schrift darstellt.
        assertTrue(str_contains($m->format(5), "\u{202F}"), 'Am Bildschirm ohne schmales Leerzeichen');
        assertFalse(str_contains($m->html(5), "\u{202F}"), 'Im Druck mit schmalem Leerzeichen');
        assertTrue(str_contains($m->html(5), "\u{00A0}"), 'Im Druck ohne geschütztes Leerzeichen');
        assertSame("5,00\u{00A0}€", $m->html(5));
    });

    test('Ein Symbol traegt keine Auszeichnung in die Seite', function () use ($waehrung) {
        // Das Zeichen steht in etlichen Ausgaben ohne weitere Maskierung.
        $m = $waehrung(['currency_code' => 'EUR', 'currency_symbol' => '<b>x</b>']);
        assertFalse(str_contains($m->symbol(), '<'), 'Spitze Klammer im Symbol: ' . $m->symbol());
        assertFalse(str_contains($m->symbol(), '>'), 'Spitze Klammer im Symbol: ' . $m->symbol());
        assertFalse(str_contains($m->format(1), '<'), 'Spitze Klammer in der Anzeige');
    });

    test('Kein Ausgabeweg traegt ein fest verdrahtetes Eurozeichen', function () {
        // PDF- und CSV-Ausgaben sollen der Einstellung folgen wie die Seiten auch.
        $wurzel  = dirname(__DIR__);
        $dateien = [
            'src/Controllers/ContractController.php',
            'src/Controllers/LicenseController.php',
            'src/Controllers/BudgetController.php',
            'src/Controllers/AssetController.php',
            'templates/catalog/pricelist.php',
            'templates/catalog/bundles_pdf.php',
            'templates/catalog/calc_pdf.php',
        ];
        $fehler = [];
        foreach ($dateien as $datei) {
            $inhalt = (string)file_get_contents($wurzel . '/' . $datei);
            if (str_contains($inhalt, '€')) {
                $fehler[] = $datei . ' (Eurozeichen)';
            }
            if (str_contains($inhalt, "(EUR)")) {
                $fehler[] = $datei . ' (Spaltenüberschrift EUR)';
            }
        }
        assertSame([], $fehler, 'Fest verdrahtet: ' . implode(', ', $fehler));
    });

    test('Keine Vorlage traegt ein fest verdrahtetes Waehrungszeichen', function () {
        // Auch die HTML-Schreibweise `&euro;` faellt darunter – sie sieht im
        // Quelltext anders aus, steht auf der Seite aber genauso fest.
        $wurzel = dirname(__DIR__) . '/templates';
        $fehler = [];
        $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($wurzel));
        foreach ($it as $datei) {
            if (!$datei->isFile() || $datei->getExtension() !== 'php') {
                continue;
            }
            $pfad = str_replace(dirname(__DIR__) . '/', '', $datei->getPathname());
            // Das Handbuch beschreibt die Oberflaeche in Prosa, mit Beispielbetraegen.
            if (str_starts_with($pfad, 'templates/admin/manual/')) {
                continue;
            }
            $inhalt = (string)file_get_contents($datei->getPathname());
            if (str_contains($inhalt, '€') || str_contains($inhalt, '&euro;')) {
                $fehler[] = $pfad;
            }
        }
        assertSame([], $fehler, 'Fest verdrahtet: ' . implode(', ', $fehler));
    });

    test('Der Katalog folgt der zentralen Waehrung', function () {
        // Eine zweite Währungseinstellung im Modul würde daneben stehen.
        $wurzel = dirname(__DIR__);
        foreach (['src/Catalog/CatalogManager.php', 'src/Controllers/CatalogController.php'] as $datei) {
            assertFalse(str_contains((string)file_get_contents($wurzel . '/' . $datei), 'catalog_currency'),
                'Eigene Währungseinstellung in ' . $datei);
        }
        foreach (['index', 'item', 'bundles', 'pricelist', 'bundles_pdf', 'calc_pdf'] as $name) {
            $tpl = (string)file_get_contents($wurzel . '/templates/catalog/' . $name . '.php');
            assertFalse(str_contains($tpl, '€'), 'Eurozeichen in catalog/' . $name . '.php');
        }
    });

    test('Eingaben laufen ueberall ueber denselben Leser', function () {
        // Zwei Leser nebeneinander waeren zwei Ergebnisse fuer dieselbe Eingabe.
        $wurzel  = dirname(__DIR__);
        $dateien = [
            'src/Controllers/LoanController.php',
            'src/Controllers/ContractController.php',
            'src/Content/LicenseManager.php',
            'src/Content/BudgetManager.php',
            'src/Assets/Valuation.php',
        ];
        $fehler = [];
        foreach ($dateien as $datei) {
            $inhalt = (string)file_get_contents($wurzel . '/' . $datei);
            if (preg_match("/str_replace\\(\\[?'\\.'/", $inhalt) === 1) {
                $fehler[] = $datei . ' (eigener Tausendertrenner-Umbau)';
            }
            if (!str_contains($inhalt, '->parse(')) {
                $fehler[] = $datei . ' (liest ohne den zentralen Leser)';
            }
        }
        assertSame([], $fehler, 'Eigener Leser: ' . implode(', ', $fehler));
    });

    test('Kein Ausgabeweg der Benachrichtigungen traegt ein festes Eurozeichen', function () {
        $wurzel  = dirname(__DIR__);
        $dateien = [
            'src/Notifications/NotificationManager.php',
            'src/Cron/Handlers/BudgetThresholdAlertHandler.php',
            'src/AI/ContextBuilder.php',
            'src/Content/ContractManager.php',
            'public/assets/js/ai-chat.js',
        ];
        $fehler = [];
        foreach ($dateien as $datei) {
            $inhalt = (string)file_get_contents($wurzel . '/' . $datei);
            if (str_contains($inhalt, '€') || str_contains($inhalt, "' EUR")) {
                $fehler[] = $datei;
            }
        }
        assertSame([], $fehler, 'Fest verdrahtet: ' . implode(', ', $fehler));
    });

    test('Das Frontend bekommt die Einstellung ueber ein Meta-Feld', function () {
        $wurzel = dirname(__DIR__);
        foreach (['templates/layouts/main.php', 'templates/layouts/admin.php'] as $layout) {
            $inhalt = (string)file_get_contents($wurzel . '/' . $layout);
            assertTrue(str_contains($inhalt, 'name="money-format"'), 'Ohne Meta-Feld: ' . $layout);
        }
        $js = (string)file_get_contents($wurzel . '/public/assets/js/app.js');
        assertTrue(str_contains($js, "meta[name=\"money-format\"]"), 'Der Helfer liest das Meta-Feld nicht');
        assertTrue(str_contains($js, 'window.OpenNIT.money'), 'Der Helfer haengt nicht am Namensraum');
    });

    test('Das Frontend bekommt dieselbe Einstellung', function () use ($waehrung) {
        // Ein im Browser erzeugter Betrag muss aussehen wie einer vom Server.
        $m = $waehrung(['currency_code' => 'CHF']);
        $js = $m->fuerJs();
        assertSame('CHF', $js['code']);
        assertSame('vor', $js['position']);
        assertSame('.', $js['decimal']);
        assertTrue(isset($js['symbol'], $js['thousand']), 'Ein Feld fehlt');
    });
};