Files
deskhub/DeskHub-Webseite/tests/i18n.php
T
2026-10-24 20:14:43 +00:00

160 lines
12 KiB
PHP
Raw 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);
/**
* Prüft die Mehrsprachigkeit: Sprachpakete als Plugin, Übersetzung der fertigen Seite (Text, Attribute, Knöpfe, JavaScript-Texte,
* Muster mit {} und {#}), Sammelmodus, Sprachwechsler mit Flaggen und den Wechsel über lang.php.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-i18n-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void {
if (!$ok) $failures[] = $what;
};
$pdo = get_db();
// ── Ohne Sprachpaket: nur Deutsch, kein Wechsler ────────────────────────────
$check(array_keys(i18n_languages()) === ['de'] && i18n_switcher_html() === '' && i18n_current() === 'de', 'ohne Sprachpaket gibt es nur Deutsch und keinen Wechsler');
// ── Sprachpaket installieren: eigenes kleines Paket und das mitgelieferte English ──
$mkZip = function (array $files) {
$path = sys_get_temp_dir() . '/lang-' . bin2hex(random_bytes(3)) . '.zip';
$z = new ZipArchive();
$z->open($path, ZipArchive::CREATE);
foreach ($files as $name => $content) $z->addFromString($name, $content);
$z->close();
return $path;
};
$flag = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="#009246"/></svg>';
$strings = [
'_info' => 'Test',
'Neues Ticket' => 'New ticket',
'Ticket erstellen' => 'Create ticket',
'Betreff' => 'Subject',
'Kurze Zusammenfassung' => 'Short summary',
'Willkommen, {}' => 'Welcome, {}',
'{} · erstellt am {}' => '{1} · created {2}',
'{#} Treffer' => '{} hits',
'Wirklich löschen?' => 'Really delete?',
'Ein <b>Test</b>' => 'A test',
'Mit &amp; Zeichen' => 'With & signs',
'Leer' => '',
];
$pkg = $mkZip([
'plugin.json' => json_encode(['slug' => 'testsprache', 'name' => 'Testsprache', 'version' => '1.0.0', 'entry' => 'plugin.php', 'permissions' => [], 'language' => ['code' => 'xx', 'name' => 'Testsprache', 'flag' => 'flag.svg', 'strings' => 'strings.json', 'htmlLang' => 'xx-TS']]),
'plugin.php' => "<?php\n",
'flag.svg' => $flag,
'strings.json' => json_encode($strings, JSON_UNESCAPED_UNICODE),
]);
$r = plugin_install_zip($pdo, $pkg);
$check($r['ok'] && $r['slug'] === 'testsprache', 'Sprachpaket lässt sich installieren: ' . json_encode($r));
$check(count(i18n_languages(true)) === 1, 'ein nicht aktiviertes Sprachpaket zählt nicht');
$act = plugin_set_active($pdo, 'testsprache', true, []);
$check($act['ok'], 'Sprachpaket braucht keine Berechtigungen und lässt sich aktivieren: ' . json_encode($act));
$langs = i18n_languages(true);
$check(array_keys($langs) === ['de', 'xx'] && $langs['xx']['name'] === 'Testsprache' && $langs['xx']['htmlLang'] === 'xx-TS' && str_starts_with($langs['xx']['flag'], 'data:image/svg+xml;base64,'), 'Sprachen: Deutsch (eingebaut) und das Paket mit Flagge als Datenadresse');
$check(i18n_dictionary('xx')['count'] === 10 && !isset(i18n_dictionary('xx')['exact']['Leer']), 'Wörterbuch: leere Werte und _info werden ignoriert');
// ── Auswahl der Sprache ─────────────────────────────────────────────────────
$check(i18n_current() === 'de', 'ohne Cookie gilt die Standardsprache');
$_COOKIE[I18N_COOKIE] = 'xx';
$check(i18n_current() === 'xx', 'Cookie wählt die Sprache');
$_COOKIE[I18N_COOKIE] = 'zz';
$check(i18n_current() === 'de', 'unbekannter Sprachcode im Cookie wird ignoriert');
unset($_COOKIE[I18N_COOKIE]);
set_setting('default_language', 'xx');
$check(i18n_default() === 'xx' && i18n_current() === 'xx', 'Standardsprache aus den Einstellungen');
set_setting('default_language', 'qq');
$check(i18n_default() === 'de', 'unbekannte Standardsprache fällt auf Deutsch zurück');
set_setting('default_language', 'de');
// ── Übersetzung ─────────────────────────────────────────────────────────────
$t = fn(string $html) => i18n_translate_html($html, 'xx');
$check($t('<h1>Neues Ticket</h1>') === '<h1>New ticket</h1>', 'Text zwischen Tags');
$check($t("<p>\n Neues Ticket \n</p>") === "<p>\n New ticket \n</p>", 'Leerraum um den Text bleibt, Leerraum im Text zählt nicht');
$check($t('<input placeholder="Kurze Zusammenfassung" title="Betreff">') === '<input placeholder="Short summary" title="Subject">', 'Attribute placeholder und title');
$check($t('<input type="submit" value="Ticket erstellen">') === '<input type="submit" value="Create ticket">', 'Beschriftung von Knöpfen');
$check($t('<input type="text" value="Ticket erstellen">') === '<input type="text" value="Ticket erstellen">', 'Eingaben (value) bleiben unberührt');
$check($t('<option value="Betreff">Betreff</option>') === '<option value="Betreff">Subject</option>', 'Optionstext ja, Wert nein');
$check($t('<textarea>Neues Ticket</textarea>') === '<textarea>Neues Ticket</textarea>', 'Inhalt von Textfeldern bleibt');
$check($t('<p>Unbekannter Text</p>') === '<p>Unbekannter Text</p>', 'ohne Übersetzung bleibt es deutsch');
$check($t('<p>Willkommen, Erika</p>') === '<p>Welcome, Erika</p>', 'Muster mit {}');
$check($t('<p>Technik · erstellt am 24.10.2026</p>') === '<p>Technik · created 24.10.2026</p>', 'Muster mit {1} und {2} (Reihenfolge)');
$check($t('<p>12 Treffer</p>') === '<p>12 hits</p>' && $t('<p>viele Treffer</p>') === '<p>viele Treffer</p>', 'Muster mit {#} nur für Zahlen');
$check($t('<p>Mit &amp; Zeichen</p>') === '<p>With &amp; signs</p>', 'Entities im Schlüssel und im Ergebnis');
$check($t('<p>Wirklich löschen?</p>') === '<p>Really delete?</p>', 'Fragezeichen');
$check($t('<button onclick="return confirm(\'Wirklich löschen?\')">x</button>') === '<button onclick="return confirm(\'Really delete?\')">x</button>', 'confirm() in Ereignis-Attributen');
$check($t('<script>if (confirm("Wirklich löschen?")) go(); var a = "Neues Ticket"; var b = "Anderes";</script>') === '<script>if (confirm("Really delete?")) go(); var a = "New ticket"; var b = "Anderes";</script>', 'Textliterale in Skripten (nur genaue Treffer)');
$check($t('<script src="/x.js"></script>') === '<script src="/x.js"></script>', 'Skript-Dateien bleiben');
$check($t('<style>.a::after{content:"Neues Ticket"}</style>') === '<style>.a::after{content:"Neues Ticket"}</style>', 'Stile bleiben');
$check($t('<html lang="de"><body>x</body></html>') === '<html lang="xx-TS"><body>x</body></html>', 'lang-Attribut passt zur Sprache');
$check($t('<img src="data:image/png;base64,AAAA" alt="Betreff">') === '<img src="data:image/png;base64,AAAA" alt="Subject">', 'Bilder: alt ja, src nein');
$check($t('<!-- Neues Ticket --><p>Neues Ticket</p>') === '<!-- Neues Ticket --><p>New ticket</p>', 'Kommentare bleiben');
$check($t('<p>Neues <b>Ticket</b></p>') === '<p>Neues <b>Ticket</b></p>', 'Text mit Markup mittendrin wird nicht zerhackt (nur ganze Textstücke)');
$check(i18n_translate_html('<p>Neues Ticket</p>', 'de') === '<p>Neues Ticket</p>', 'Deutsch verändert nichts');
// ── Sammelmodus ─────────────────────────────────────────────────────────────
$seen = [];
i18n_translate_html('<h1>Neues Ticket</h1><p>Völlig unbekannter Satz hier</p><p>42</p><a href="x">https://beispiel.de</a><span title="Ein Tooltip hier">x</span><script>if (confirm("Soll das wirklich weg?")) {}</script>', 'xx', $seen);
$check(isset($seen['Völlig unbekannter Satz hier']) && isset($seen['Ein Tooltip hier']) && isset($seen['Soll das wirklich weg?']) && !isset($seen['Neues Ticket']) && !isset($seen['42']) && !isset($seen['https://beispiel.de']), 'gesammelt wird nur Unübersetztes, keine Zahlen und Adressen: ' . json_encode(array_keys($seen)));
i18n_remember(array_keys($seen));
i18n_remember(['Völlig unbekannter Satz hier']);
$row = $pdo->query("SELECT hits FROM i18n_missing WHERE text = 'Völlig unbekannter Satz hier'")->fetchColumn();
$check((int) $pdo->query('SELECT COUNT(*) FROM i18n_missing')->fetchColumn() === 3 && (int) $row === 2, 'unübersetzte Texte werden gespeichert und gezählt');
// ── Ausgabe-Filter ──────────────────────────────────────────────────────────
$GLOBALS['__i18n_code'] = 'xx';
$check(i18n_output_filter('<!doctype html><html lang="de"><body><h1>Neues Ticket</h1></body></html>') === '<!doctype html><html lang="xx-TS"><body><h1>New ticket</h1></body></html>', 'Ausgabe-Filter übersetzt die Seite');
$check(i18n_output_filter('{"a":"Neues Ticket"}') === false, 'JSON bleibt unverändert (keine HTML-Tags)');
// ── Sprachwechsler ──────────────────────────────────────────────────────────
$_SERVER['REQUEST_URI'] = '/ticket_new.php?x=1';
$sw = i18n_switcher_html();
$check(substr_count($sw, '<img') === 2 && str_contains($sw, '/lang.php?l=xx&amp;back=%2Fticket_new.php%3Fx%3D1') && str_contains($sw, '/lang.php?l=de') && str_contains($sw, 'aria-current'), 'Wechsler: zwei Flaggen, Links mit Rücksprung, aktive Sprache markiert');
$check(str_contains(i18n_switcher_html('floating'), 'position:fixed') && !str_contains($sw, 'position:fixed'), 'Wechsler: feste Position nur für Anmeldeseiten');
$check(i18n_safe_back('/staff/tickets.php?a=1') === '/staff/tickets.php?a=1' && i18n_safe_back('//evil.example') === '/' && i18n_safe_back('https://evil.example') === '/' && i18n_safe_back("/a\r\nSet-Cookie: x") === '/' && i18n_safe_back('/\\evil') === '/' && i18n_safe_back('') === '/', 'Rücksprung nur auf Adressen dieser Seite');
// ── Das mitgelieferte English-Paket ─────────────────────────────────────────
$enSrc = plugin_source_dir_or_skip('lang-en', 'i18n');
$deSrc = plugin_source_dir_or_skip('lang-de', 'i18n');
foreach ([$enSrc, $deSrc] as $src) {
$zp = sys_get_temp_dir() . '/lp-' . bin2hex(random_bytes(3)) . '.zip';
$z = new ZipArchive();
$z->open($zp, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) $z->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
$z->close();
$ri = plugin_install_zip($pdo, $zp);
$check($ri['ok'], 'mitgeliefertes Paket installierbar: ' . ($ri['error'] ?? $ri['slug']));
$slug = (string) ($ri['slug'] ?? '');
$check(plugin_set_active($pdo, $slug, true, [])['ok'], "Paket {$slug} aktivierbar");
@unlink($zp);
}
$langs = i18n_languages(true);
$check(isset($langs['en'], $langs['de'], $langs['xx']) && $langs['de']['slug'] === 'lang-de' && $langs['en']['name'] === 'English' && $langs['en']['flag'] !== '', 'English und Deutsch (als Plugin) sind da');
$en = i18n_dictionary('en');
$check($en['count'] > 900, 'English hat viele Übersetzungen: ' . $en['count']);
$check(i18n_translate_html('<h1>Neues Ticket erstellen</h1><label>Betreff</label><p>Willkommen, Erika</p><a>Meine Tickets – Test</a>', 'en') === '<h1>Create new ticket</h1><label>Subject</label><p>Welcome, Erika</p><a>My tickets – Test</a>', 'English übersetzt Kundenportal-Texte');
$check(i18n_translate_html('<th>Abteilungen</th><button>Speichern</button><span>Abmelden</span>', 'en') === '<th>Departments</th><button>Save</button><span>Sign out</span>', 'English übersetzt Texte des Mitarbeiterbereichs');
// ── Aufräumen ───────────────────────────────────────────────────────────────
@unlink($pkg);
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo 'Mehrsprachigkeit checks passed.' . "\n";