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:
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