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:
Hermes
2026-06-04 08:53:58 +00:00
parent f9295a2d07
commit 63c8c759d2
5 changed files with 807 additions and 0 deletions

198
tests/Core/I18nTest.php Normal file
View 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
View 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']);
}
}