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:
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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user