feat(i18n): core Locale resolver + I18n t()-helper with tests (closes #72)
- App\Core\Locale: query-param > cookie > Accept-Language > 'de' fallback
- BCP-47 region stripping (en-US -> en, uk-UA -> uk)
- q-value sorting with stable order
- og:locale mapping (de_DE, en_GB, uk_UA, ru_RU)
- hreflang alternates helper
- App\Core\I18n: t() with {placeholder} interpolation, lookup chain
current-locale -> de -> key, in-memory cache
- ADR-002: documents the architecture decision
- 46 PHPUnit tests (LocaleTest, I18nTest), all green
This commit is contained in:
140
app/Core/I18n.php
Normal file
140
app/Core/I18n.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Translation loader + t() helper.
|
||||
*
|
||||
* Loads flat `key => 'text'` arrays from `app/Locales/{locale}.php` once per
|
||||
* request per locale, caches in static memory. Supports {placeholder}
|
||||
* interpolation.
|
||||
*
|
||||
* Fallback chain: current locale → 'de' → key string itself (with optional
|
||||
* missing-key indicator in dev).
|
||||
*
|
||||
* Stateless on the instance — `t()` is a static method so views can call
|
||||
* it without a container.
|
||||
*/
|
||||
final class I18n
|
||||
{
|
||||
/** @var array<string, array<string,string>> locale => [key => text] */
|
||||
private static array $cache = [];
|
||||
|
||||
/** @var string|null Path to the Locales directory (overridable for tests) */
|
||||
private static ?string $localesPath = null;
|
||||
|
||||
/**
|
||||
* Translate a key in the current locale, with {placeholder} interpolation.
|
||||
*
|
||||
* @param string $key Dotted key, e.g. 'nav.gallery'
|
||||
* @param array<string,string> $params Placeholders: ['name' => 'Martin']
|
||||
* @param string|null $locale Override locale (defaults to current)
|
||||
*/
|
||||
public static function t(string $key, array $params = [], ?string $locale = null): string
|
||||
{
|
||||
$locale ??= Locale::DEFAULT;
|
||||
|
||||
// Unsupported locale = likely a developer bug; surface the key
|
||||
// rather than silently falling back to DE.
|
||||
if (!Locale::isSupported($locale)) {
|
||||
return self::interpolate($key, $params);
|
||||
}
|
||||
|
||||
$text = self::lookup($key, $locale);
|
||||
if ($text === null && $locale !== Locale::DEFAULT) {
|
||||
$text = self::lookup($key, Locale::DEFAULT);
|
||||
}
|
||||
$text ??= $key;
|
||||
return self::interpolate($text, $params);
|
||||
|
||||
if ($params !== []) {
|
||||
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
|
||||
$replace = array_values($params);
|
||||
$text = str_replace($search, $replace, $text);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plural-aware translation. MVP: no ICU — we just append `{n}` to the
|
||||
* key so the caller provides singular and plural variants.
|
||||
*
|
||||
* @param array<string,string> $params
|
||||
*/
|
||||
public static function tn(string $keySingular, string $keyPlural, int $n, array $params = [], ?string $locale = null): string
|
||||
{
|
||||
$key = $n === 1 ? $keySingular : $keyPlural;
|
||||
$params = array_merge($params, ['n' => (string) $n]);
|
||||
return self::t($key, $params, $locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a key exists in the given locale (or the default).
|
||||
* Useful for tests and conditional UI logic.
|
||||
*/
|
||||
public static function has(string $key, ?string $locale = null): bool
|
||||
{
|
||||
$locale ??= Locale::DEFAULT;
|
||||
return self::lookup($key, $locale) !== null
|
||||
|| ($locale !== Locale::DEFAULT && self::lookup($key, Locale::DEFAULT) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the in-memory cache. Test-only utility.
|
||||
*/
|
||||
public static function flushCache(): void
|
||||
{
|
||||
self::$cache = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the Locales directory. Test-only utility.
|
||||
*/
|
||||
public static function setLocalesPath(string $path): void
|
||||
{
|
||||
self::$localesPath = $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a key in a specific locale's array.
|
||||
*/
|
||||
private static function interpolate(string $text, array $params): string
|
||||
{
|
||||
if ($params === []) {
|
||||
return $text;
|
||||
}
|
||||
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
|
||||
$replace = array_values($params);
|
||||
return str_replace($search, $replace, $text);
|
||||
}
|
||||
|
||||
private static function lookup(string $key, string $locale): ?string
|
||||
{
|
||||
if (!Locale::isSupported($locale)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset(self::$cache[$locale])) {
|
||||
$file = self::localesPath() . '/' . $locale . '.php';
|
||||
if (!is_file($file)) {
|
||||
self::$cache[$locale] = [];
|
||||
return null;
|
||||
}
|
||||
$data = require $file;
|
||||
self::$cache[$locale] = is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
return self::$cache[$locale][$key] ?? null;
|
||||
}
|
||||
|
||||
private static function localesPath(): string
|
||||
{
|
||||
if (self::$localesPath !== null) {
|
||||
return self::$localesPath;
|
||||
}
|
||||
return dirname(__DIR__) . '/Locales';
|
||||
}
|
||||
}
|
||||
162
app/Core/Locale.php
Normal file
162
app/Core/Locale.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
/**
|
||||
* Locale resolution: query-param → cookie → Accept-Language → fallback 'de'.
|
||||
*
|
||||
* Immutable. No globals on the instance — all input is passed explicitly so
|
||||
* the class is trivial to unit-test.
|
||||
*
|
||||
* Supported locales: 'de' (default), 'en', 'uk', 'ru'.
|
||||
*
|
||||
* The class is a thin value object; resolution is a static method so it
|
||||
* can be called from anywhere (controllers, views, tests) without wiring.
|
||||
*/
|
||||
final class Locale
|
||||
{
|
||||
public const DEFAULT = 'de';
|
||||
|
||||
/** @var list<string> ISO 639-1 codes, de is the source of truth */
|
||||
public const SUPPORTED = ['de', 'en', 'uk', 'ru'];
|
||||
|
||||
/**
|
||||
* Resolve a locale from request signals.
|
||||
*
|
||||
* Priority: explicit query/cookie > Accept-Language header > default.
|
||||
*
|
||||
* @param string|null $queryParam Value of ?lang= (raw, unvalidated)
|
||||
* @param string|null $cookieValue Value of the 'locale' cookie (raw)
|
||||
* @param string|null $acceptLanguage Raw Accept-Language header
|
||||
*/
|
||||
public static function resolve(
|
||||
?string $queryParam = null,
|
||||
?string $cookieValue = null,
|
||||
?string $acceptLanguage = null,
|
||||
): string {
|
||||
// 1. Query param wins (one-shot, used by LocaleController to set cookie)
|
||||
if (is_string($queryParam) && self::isSupported($queryParam)) {
|
||||
return $queryParam;
|
||||
}
|
||||
|
||||
// 2. Cookie next
|
||||
if (is_string($cookieValue) && self::isSupported($cookieValue)) {
|
||||
return $cookieValue;
|
||||
}
|
||||
|
||||
// 3. Accept-Language header
|
||||
if (is_string($acceptLanguage) && $acceptLanguage !== '') {
|
||||
$parsed = self::parseAcceptLanguage($acceptLanguage);
|
||||
foreach ($parsed as $tag) {
|
||||
if (self::isSupported($tag)) {
|
||||
return $tag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return self::DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an Accept-Language header into a list of ISO 639-1 codes
|
||||
* sorted by q-value (highest first), with q=0 entries dropped.
|
||||
*
|
||||
* Handles wildcards ("*") and BCP-47 subtags ("en-US" → "en",
|
||||
* "uk-UA" → "uk"). Entries with the same q-value keep header order
|
||||
* (stable).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function parseAcceptLanguage(string $header): array
|
||||
{
|
||||
$header = trim($header);
|
||||
if ($header === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
foreach (explode(',', $header) as $i => $part) {
|
||||
$parts = explode(';', trim($part));
|
||||
$tag = trim($parts[0]);
|
||||
$q = 1.0;
|
||||
|
||||
for ($j = 1; $j < count($parts); $j++) {
|
||||
if (preg_match('/^q\s*=\s*([0-9.]+)$/i', trim($parts[$j]), $m)) {
|
||||
$q = (float) $m[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ($q <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip BCP-47 region: "en-US" → "en", "uk-UA" → "uk"
|
||||
$primary = strtolower(explode('-', $tag)[0]);
|
||||
if ($primary === '*' || $primary === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort key: -q (descending) and original position (ascending)
|
||||
$entries[] = [
|
||||
'tag' => $primary,
|
||||
'q' => $q,
|
||||
'pos' => $i,
|
||||
];
|
||||
}
|
||||
|
||||
usort($entries, static function (array $a, array $b): int {
|
||||
if ($a['q'] !== $b['q']) {
|
||||
return $b['q'] <=> $a['q'];
|
||||
}
|
||||
return $a['pos'] <=> $b['pos'];
|
||||
});
|
||||
|
||||
return array_values(array_map(static fn (array $e): string => $e['tag'], $entries));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a code is in {@see self::SUPPORTED}.
|
||||
*/
|
||||
public static function isSupported(string $code): bool
|
||||
{
|
||||
return in_array($code, self::SUPPORTED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map ISO 639-1 → BCP-47 og:locale format.
|
||||
* Used by View layout for <meta property="og:locale">.
|
||||
*/
|
||||
public static function toOgLocale(string $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
'de' => 'de_DE',
|
||||
'en' => 'en_GB', // UK English by user requirement
|
||||
'uk' => 'uk_UA',
|
||||
'ru' => 'ru_RU',
|
||||
default => 'de_DE',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full hreflang alternate list for the current page, given its
|
||||
* canonical path. Returns an array of ['locale' => 'hreflang', 'href' => url].
|
||||
*
|
||||
* @return list<array{locale:string, hreflang:string, href:string}>
|
||||
*/
|
||||
public static function hreflangAlternates(string $canonicalPath, string $baseUrl = 'https://haus-schleusingen.de'): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::SUPPORTED as $code) {
|
||||
$hreflang = $code === 'en' ? 'en-GB' : ($code === 'uk' ? 'uk' : $code);
|
||||
$out[] = [
|
||||
'locale' => $code,
|
||||
'hreflang' => $hreflang,
|
||||
'href' => $baseUrl . $canonicalPath . '?lang=' . $code,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
123
docs/adr/002-multilanguage-architecture.md
Normal file
123
docs/adr/002-multilanguage-architecture.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ADR-002: Multi-Language Architecture (i18n)
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-04
|
||||
**Context:** Issue #71 (Epic: Multi-Language: UK/RU/EN — DE bleibt für Rechtliches)
|
||||
**Deciders:** Martin (Product Owner), Hermes (Implementation)
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
The landing page `landingpage-haus-schleusingen.de` is currently German-only.
|
||||
It must support 4 languages: **DE** (default, for legal content), **EN** (UK
|
||||
English), **UK** (Ukrainian), **RU** (Russian). The site is SEO-critical (real
|
||||
estate listing), has no build step, and runs on stock PHP 8.x on shared hosting.
|
||||
|
||||
The challenge: ship server-side rendering for SEO + no FOUC, without dragging
|
||||
in a heavy framework or build pipeline.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **PHP-Server-Side-Rendering with `app/Locales/*.php` arrays + `t()` helper**
|
||||
(chosen)
|
||||
2. JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO)
|
||||
3. Full Symfony/translation component (rejected — overkill for 1-page site)
|
||||
4. Static-site per language (`/de/`, `/en/`, `/uk/`, `/ru/` directories)
|
||||
(rejected — duplicates routes/forms, harder to maintain)
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 1: PHP SSR with `app/Locales/*.php` and a `t()` helper.**
|
||||
|
||||
### Components
|
||||
|
||||
- **`App\Core\Locale`** — locale resolution (priority: query-param `?lang=`
|
||||
→ cookie → `Accept-Language` header → fallback `de`). Immutable, no
|
||||
globals. Available locales: `['de', 'en', 'uk', 'ru']`.
|
||||
- **`App\Core\I18n`** — translation loader + `t(string $key, array $params = [])`
|
||||
function. Loads `app/Locales/{locale}.php` lazily, caches in static array.
|
||||
Supports `{placeholder}` interpolation. Falls back to `de` if a key is
|
||||
missing in the current locale, then to the key itself.
|
||||
- **`App\Controllers\LocaleController`** — `GET /locale/{locale}` sets a
|
||||
one-year `locale` cookie and 302-redirects to `Referer` (or `/`).
|
||||
- **`app/Locales/{de,en,uk,ru}.php`** — flat `key => 'text'` arrays. Keys use
|
||||
dotted notation (`nav.gallery`, `hero.cta`, `form.error.email`).
|
||||
- **Layout** — `app/views/layouts/main.php` reads current locale from
|
||||
`Locale::current()` and renders dynamic `<html lang="…">` + `og:locale`.
|
||||
- **Switcher UI** — in `app/views/partials/locale_switcher.php`, embedded in
|
||||
navbar. Inline SVG flag icons (no external assets).
|
||||
|
||||
### Locale Resolution Order
|
||||
|
||||
1. Query parameter `?lang=xx` (one-shot, sets cookie)
|
||||
2. Cookie `locale` (1 year, `SameSite=Lax`, no `Secure` flag for HTTP test
|
||||
hosts, `Secure` flag in prod via env check)
|
||||
3. `Accept-Language` header — first matching language from
|
||||
`['en-US', 'en', 'uk', 'ru']` (BCP-47 → ISO 639-1 mapping)
|
||||
4. Fallback: `de`
|
||||
|
||||
### Translation File Format
|
||||
|
||||
```php
|
||||
// app/Locales/de.php
|
||||
return [
|
||||
'nav.gallery' => 'Galerie',
|
||||
'hero.cta' => 'Jetzt anfragen',
|
||||
'form.label.email' => 'E-Mail',
|
||||
];
|
||||
```
|
||||
|
||||
`{placeholder}` interpolation, e.g.:
|
||||
|
||||
```php
|
||||
'greeting' => 'Hallo, {name}!',
|
||||
echo t('greeting', ['name' => 'Martin']); // "Hallo, Martin!"
|
||||
```
|
||||
|
||||
### Out of Scope (this MVP)
|
||||
|
||||
- Right-to-left languages (Arabic, Hebrew)
|
||||
- Plural forms (`{n,plural,one{...}other{...}}` ICU syntax) — flat strings only
|
||||
- Admin UI for editing translations (POEditor, Crowdin, etc.)
|
||||
- Per-page translation overrides
|
||||
- URL-based locale (`/en/`, `/uk/`) — cookie + query only for MVP
|
||||
|
||||
### Trade-offs Accepted
|
||||
|
||||
- **No URL-based locale** → weaker SEO signal for non-default languages.
|
||||
Mitigation: `og:locale` + `<html lang>` + hreflang tags (TODO post-MVP).
|
||||
- **No ICU plural** → manual `{n} Zimmer` strings. Acceptable: page has
|
||||
fixed numbers (`227 m²`, `6 Zimmer`).
|
||||
- **Flat key namespace** (`nav.gallery` vs nested arrays) → slightly more
|
||||
verbose but trivially diff-able in PRs and avoids PHP array-merging
|
||||
surprises.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero new dependencies** (no Composer additions, no JS framework)
|
||||
- **SEO-perfect** — fully server-rendered, no FOUC
|
||||
- **Trivially testable** — pure PHP, no globals, no I/O at request time
|
||||
(files loaded once, cached)
|
||||
- **Diff-friendly** — translation files are flat PHP arrays
|
||||
- **Fast** — locale detection is in-memory; translation load happens once
|
||||
per request, per locale
|
||||
|
||||
### Negative
|
||||
|
||||
- Adding a new key requires touching all 4 files (mitigated by `missing
|
||||
key → fallback to DE → fallback to key string`)
|
||||
- No URL canonicalization for non-DE locales (mitigated post-MVP with
|
||||
hreflang)
|
||||
- Manual translation review (no professional translator for UK/RU in MVP)
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
Issue #71 (Epic) → #72–#77 (6 sub-issues, dependency-ordered).
|
||||
|
||||
## References
|
||||
|
||||
- Issue #71: https://git.home.kies-media.de/greggy/landingpage-haus-schleusingen/issues/71
|
||||
- Issue #72–#77: sub-issues, all in Milestone "Multi-Language MVP"
|
||||
- W3C i18n tutorials: https://www.w3.org/International/tutorials/
|
||||
- BCP-47 language tags: https://datatracker.ietf.org/doc/html/rfc5646
|
||||
198
tests/Core/I18nTest.php
Normal file
198
tests/Core/I18nTest.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Core;
|
||||
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class I18nTest extends TestCase
|
||||
{
|
||||
/** @var string */
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/i18n-test-' . bin2hex(random_bytes(4));
|
||||
mkdir($this->tmpDir);
|
||||
I18n::setLocalesPath($this->tmpDir);
|
||||
I18n::flushCache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
I18n::flushCache();
|
||||
I18n::setLocalesPath(dirname(__DIR__, 2) . '/app/Locales');
|
||||
if (is_dir($this->tmpDir)) {
|
||||
foreach (glob($this->tmpDir . '/*') as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($this->tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function writeLocale(string $code, array $data): void
|
||||
{
|
||||
$content = '<?php return ' . var_export($data, true) . ';';
|
||||
file_put_contents($this->tmpDir . '/' . $code . '.php', $content);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// t(): basic lookup
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testReturnsKeyWhenNoLocalesExist(): void
|
||||
{
|
||||
$this->assertSame('missing.key', I18n::t('missing.key'));
|
||||
}
|
||||
|
||||
public function testReturnsKeyWhenLocaleFileMissing(): void
|
||||
{
|
||||
// Only DE file exists
|
||||
$this->writeLocale('de', ['hello' => 'Hallo']);
|
||||
$this->assertSame('missing.key', I18n::t('missing.key', [], 'en'));
|
||||
}
|
||||
|
||||
public function testReturnsKeyWhenKeyMissingInAllLocales(): void
|
||||
{
|
||||
$this->writeLocale('de', ['hello' => 'Hallo']);
|
||||
$this->writeLocale('en', ['other' => 'Other']);
|
||||
$this->assertSame('greeting', I18n::t('greeting', [], 'en'));
|
||||
}
|
||||
|
||||
public function testFallsBackToDeWhenKeyMissingInCurrentLocale(): void
|
||||
{
|
||||
$this->writeLocale('de', ['nav.home' => 'Start']);
|
||||
$this->writeLocale('en', ['other.key' => 'Other']);
|
||||
$this->assertSame('Start', I18n::t('nav.home', [], 'en'));
|
||||
}
|
||||
|
||||
public function testReturnsValueInCurrentLocale(): void
|
||||
{
|
||||
$this->writeLocale('de', ['greeting' => 'Hallo']);
|
||||
$this->writeLocale('en', ['greeting' => 'Hello']);
|
||||
$this->assertSame('Hallo', I18n::t('greeting', [], 'de'));
|
||||
$this->assertSame('Hello', I18n::t('greeting', [], 'en'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// t(): placeholders
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testInterpolatesPlaceholders(): void
|
||||
{
|
||||
$this->writeLocale('de', ['welcome' => 'Willkommen, {name}!']);
|
||||
$this->assertSame(
|
||||
'Willkommen, Martin!',
|
||||
I18n::t('welcome', ['name' => 'Martin'], 'de')
|
||||
);
|
||||
}
|
||||
|
||||
public function testInterpolatesMultiplePlaceholders(): void
|
||||
{
|
||||
$this->writeLocale('de', ['mail' => '{greeting}, deine Bestellung #{order} ist da.']);
|
||||
$this->assertSame(
|
||||
'Hi, deine Bestellung #42 ist da.',
|
||||
I18n::t('mail', ['greeting' => 'Hi', 'order' => '42'], 'de')
|
||||
);
|
||||
}
|
||||
|
||||
public function testLeavesUnreplacedPlaceholdersAlone(): void
|
||||
{
|
||||
$this->writeLocale('de', ['x' => 'Hallo {name}']);
|
||||
$this->assertSame('Hallo {name}', I18n::t('x', [], 'de'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// t(): default locale behavior
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testDefaultsToDeLocaleWhenNoneSpecified(): void
|
||||
{
|
||||
$this->writeLocale('de', ['greeting' => 'Hallo']);
|
||||
$this->assertSame('Hallo', I18n::t('greeting'));
|
||||
}
|
||||
|
||||
public function testRejectsUnsupportedLocaleAndReturnsKey(): void
|
||||
{
|
||||
$this->writeLocale('de', ['greeting' => 'Hallo']);
|
||||
$this->assertSame('greeting', I18n::t('greeting', [], 'fr'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// tn(): plural variants (MVP: {n} interpolation)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testTnPicksSingularForOne(): void
|
||||
{
|
||||
$this->writeLocale('de', [
|
||||
'room.singular' => '1 Zimmer',
|
||||
'room.plural' => '{n} Zimmer',
|
||||
]);
|
||||
$this->assertSame('1 Zimmer', I18n::tn('room.singular', 'room.plural', 1, [], 'de'));
|
||||
}
|
||||
|
||||
public function testTnPicksPluralForOtherNumbers(): void
|
||||
{
|
||||
$this->writeLocale('de', [
|
||||
'room.singular' => '1 Zimmer',
|
||||
'room.plural' => '{n} Zimmer',
|
||||
]);
|
||||
$this->assertSame('6 Zimmer', I18n::tn('room.singular', 'room.plural', 6, [], 'de'));
|
||||
$this->assertSame('0 Zimmer', I18n::tn('room.singular', 'room.plural', 0, [], 'de'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// has()
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testHasReturnsTrueForExistingKey(): void
|
||||
{
|
||||
$this->writeLocale('de', ['greeting' => 'Hallo']);
|
||||
$this->writeLocale('en', ['greeting' => 'Hello']);
|
||||
$this->assertTrue(I18n::has('greeting', 'en'));
|
||||
$this->assertTrue(I18n::has('greeting', 'de'));
|
||||
}
|
||||
|
||||
public function testHasReturnsTrueForFallbackKey(): void
|
||||
{
|
||||
$this->writeLocale('de', ['only_de' => 'Nur DE']);
|
||||
$this->assertTrue(I18n::has('only_de', 'en'));
|
||||
}
|
||||
|
||||
public function testHasReturnsFalseForMissingKey(): void
|
||||
{
|
||||
$this->writeLocale('de', ['x' => 'X']);
|
||||
$this->assertFalse(I18n::has('nope', 'de'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Caching
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testCacheSurvivesAcrossCalls(): void
|
||||
{
|
||||
$this->writeLocale('de', ['k' => 'v1']);
|
||||
$this->assertSame('v1', I18n::t('k', [], 'de'));
|
||||
|
||||
// Mutate the file — cached value should still be returned
|
||||
$this->writeLocale('de', ['k' => 'v2']);
|
||||
$this->assertSame('v1', I18n::t('k', [], 'de'));
|
||||
|
||||
// Flush — now we see the new value
|
||||
I18n::flushCache();
|
||||
$this->assertSame('v2', I18n::t('k', [], 'de'));
|
||||
}
|
||||
|
||||
public function testFlushCacheClearsAllLocales(): void
|
||||
{
|
||||
$this->writeLocale('de', ['k' => 'de-v']);
|
||||
$this->writeLocale('en', ['k' => 'en-v']);
|
||||
I18n::t('k', [], 'en');
|
||||
I18n::flushCache();
|
||||
$this->writeLocale('en', ['k' => 'en-v2']);
|
||||
$this->assertSame('en-v2', I18n::t('k', [], 'en'));
|
||||
}
|
||||
}
|
||||
184
tests/Core/LocaleTest.php
Normal file
184
tests/Core/LocaleTest.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Core;
|
||||
|
||||
use App\Core\Locale;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class LocaleTest extends TestCase
|
||||
{
|
||||
// ──────────────────────────────────────────────
|
||||
// resolve(): priority order
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testResolveReturnsDefaultWhenNoSignals(): void
|
||||
{
|
||||
$this->assertSame('de', Locale::resolve());
|
||||
}
|
||||
|
||||
public function testQueryParamWinsOverCookieAndHeader(): void
|
||||
{
|
||||
$this->assertSame('en', Locale::resolve('en', 'ru', 'uk'));
|
||||
}
|
||||
|
||||
public function testCookieWinsOverHeader(): void
|
||||
{
|
||||
$this->assertSame('ru', Locale::resolve(null, 'ru', 'en'));
|
||||
}
|
||||
|
||||
public function testHeaderUsedWhenNoQueryOrCookie(): void
|
||||
{
|
||||
$this->assertSame('en', Locale::resolve(null, null, 'en-US,de;q=0.9'));
|
||||
}
|
||||
|
||||
public function testFallsBackToDefaultWhenHeaderDoesNotMatch(): void
|
||||
{
|
||||
$this->assertSame('de', Locale::resolve(null, null, 'fr-FR,it-IT'));
|
||||
}
|
||||
|
||||
public function testInvalidQueryParamIsSkippedAndCookieWins(): void
|
||||
{
|
||||
// Invalid query (e.g. 'fr' which is not supported) is treated as
|
||||
// "no signal" from that source — we fall through to the next source.
|
||||
$this->assertSame('en', Locale::resolve('fr', 'en', 'en'));
|
||||
}
|
||||
|
||||
public function testEmptyStringsAreTreatedAsNoSignal(): void
|
||||
{
|
||||
$this->assertSame('en', Locale::resolve('', '', 'en'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// isSupported()
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
#[DataProvider('provideSupportedLocales')]
|
||||
public function testIsSupportedReturnsTrueForKnownLocales(string $code): void
|
||||
{
|
||||
$this->assertTrue(Locale::isSupported($code));
|
||||
}
|
||||
|
||||
#[DataProvider('provideUnsupportedLocales')]
|
||||
public function testIsSupportedReturnsFalseForUnknownLocales(string $code): void
|
||||
{
|
||||
$this->assertFalse(Locale::isSupported($code));
|
||||
}
|
||||
|
||||
public static function provideSupportedLocales(): array
|
||||
{
|
||||
return [
|
||||
'german' => ['de'],
|
||||
'uk-english' => ['en'],
|
||||
'ukrainian' => ['uk'],
|
||||
'russian' => ['ru'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function provideUnsupportedLocales(): array
|
||||
{
|
||||
return [
|
||||
'french' => ['fr'],
|
||||
'empty' => [''],
|
||||
'upper' => ['DE'],
|
||||
'region' => ['de-DE'],
|
||||
'wildcard' => ['*'],
|
||||
'garbage' => ['xx'],
|
||||
];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// parseAcceptLanguage()
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testParseAcceptLanguageReturnsEmptyForEmptyHeader(): void
|
||||
{
|
||||
$this->assertSame([], Locale::parseAcceptLanguage(''));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageHandlesSingleTag(): void
|
||||
{
|
||||
$this->assertSame(['en'], Locale::parseAcceptLanguage('en'));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageSortsByQValue(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['ru', 'en', 'de'],
|
||||
Locale::parseAcceptLanguage('de;q=0.5,en;q=0.8,ru;q=0.9')
|
||||
);
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageDefaultsQTo1(): void
|
||||
{
|
||||
$this->assertSame(['de', 'en'], Locale::parseAcceptLanguage('de,en;q=0.5'));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageDropsQ0(): void
|
||||
{
|
||||
// 'en;q=0' is explicitly forbidden by the client → drop it.
|
||||
// 'de;q=1' is the only one left.
|
||||
$this->assertSame(['de'], Locale::parseAcceptLanguage('en;q=0,de;q=1'));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageStripsBcp47Region(): void
|
||||
{
|
||||
$this->assertSame(['en', 'de'], Locale::parseAcceptLanguage('en-US,de-DE'));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageDropsWildcard(): void
|
||||
{
|
||||
$this->assertSame([], Locale::parseAcceptLanguage('*'));
|
||||
// Wildcard plus q=0 → nothing to use
|
||||
$this->assertSame([], Locale::parseAcceptLanguage('*,en;q=0'));
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageIsStableForEqualQValues(): void
|
||||
{
|
||||
$this->assertSame(
|
||||
['en', 'de', 'uk'],
|
||||
Locale::parseAcceptLanguage('en;q=0.8,de;q=0.8,uk;q=0.8')
|
||||
);
|
||||
}
|
||||
|
||||
public function testParseAcceptLanguageHandlesWhitespace(): void
|
||||
{
|
||||
$this->assertSame(['en', 'de'], Locale::parseAcceptLanguage(' en , de ; q=0.5 '));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// toOgLocale()
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testToOgLocaleMapsToBcp47(): void
|
||||
{
|
||||
$this->assertSame('de_DE', Locale::toOgLocale('de'));
|
||||
$this->assertSame('en_GB', Locale::toOgLocale('en'));
|
||||
$this->assertSame('uk_UA', Locale::toOgLocale('uk'));
|
||||
$this->assertSame('ru_RU', Locale::toOgLocale('ru'));
|
||||
}
|
||||
|
||||
public function testToOgLocaleFallsBackToDeForUnknown(): void
|
||||
{
|
||||
$this->assertSame('de_DE', Locale::toOgLocale('fr'));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// hreflangAlternates()
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function testHreflangAlternatesBuildsFullSet(): void
|
||||
{
|
||||
$alts = Locale::hreflangAlternates('/');
|
||||
|
||||
$this->assertCount(4, $alts);
|
||||
$locales = array_column($alts, 'locale');
|
||||
$this->assertSame(['de', 'en', 'uk', 'ru'], $locales);
|
||||
|
||||
$en = array_values(array_filter($alts, static fn ($a) => $a['locale'] === 'en'))[0];
|
||||
$this->assertSame('en-GB', $en['hreflang']);
|
||||
$this->assertStringContainsString('?lang=en', $en['href']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user