The previous inline flag SVGs were visually broken — most notably the 'en' Union Jack, which was reduced to a single X plus a cross and did not resemble the real flag at all. The 'de' and 'ru' stripes also had slight off-by-pixel rounding errors. Switched to lipis/flag-icons (CC-BY 4.0) shipped as static files under public/img/flags/. These are the canonical, professionally-designed flag icons with correct proportions and all the details of the real flags. Loaded via plain <img> tags (no JS, no external CDN at runtime, no FOUC, no extra request after the page is cached). Locale code mapping: en -> gb (per ADR-002, en = en-GB). Unknown locales fall back to a 1x1 transparent gif so the layout stays intact.
154 lines
6.1 KiB
PHP
154 lines
6.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Tests\Controllers;
|
||
|
||
use App\Controllers\LocaleSwitcher;
|
||
use App\Core\Locale;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/**
|
||
* Renders the language switcher widget and checks that:
|
||
* - exactly one <details class="locale-switcher"> dropdown,
|
||
* - 4 menu items, one per supported locale,
|
||
* - the active locale is marked aria-current="true" and is a <span>,
|
||
* - inactive locales are <a> links to /locale?set=...&return=...,
|
||
* - the trigger and every menu item contain a flag SVG,
|
||
* - the rendered label is in the current locale's language.
|
||
*/
|
||
final class LocaleSwitcherTest extends TestCase
|
||
{
|
||
#[Test]
|
||
public function rendersSingleDropdownForAllSupportedLocales(): void
|
||
{
|
||
$html = (new LocaleSwitcher('en', '/'))->render();
|
||
|
||
// exactly one <details class="locale-switcher"> (no -mobile suffix, no desktop <ul>)
|
||
self::assertStringContainsString('<details class="locale-switcher">', $html);
|
||
self::assertStringNotContainsString('locale-switcher-mobile', $html);
|
||
self::assertStringNotContainsString('<ul class="locale-switcher"', $html);
|
||
self::assertStringNotContainsString('locale-switcher__item', $html);
|
||
|
||
// the menu lists all 4 supported locales
|
||
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 menu options');
|
||
|
||
// The 3 inactive locales render as <a hreflang="..">. The active
|
||
// locale renders as <span lang=".."> (no hreflang). Together all
|
||
// 4 must be present in either form.
|
||
foreach (Locale::SUPPORTED as $code) {
|
||
self::assertTrue(
|
||
str_contains($html, 'hreflang="' . $code . '"') || str_contains($html, 'lang="' . $code . '"'),
|
||
"locale '$code' is missing from switcher",
|
||
);
|
||
}
|
||
|
||
// 1 flag in trigger + 4 flags in menu = 5 total
|
||
self::assertSame(5, substr_count($html, 'class="flag"'), 'expected 5 flag SVGs (1 trigger + 4 menu)');
|
||
}
|
||
|
||
#[Test]
|
||
public function marksCurrentLocaleWithAriaCurrentAndSpan(): void
|
||
{
|
||
$html = (new LocaleSwitcher('uk', '/'))->render();
|
||
self::assertStringContainsString('is-current', $html);
|
||
self::assertStringContainsString('aria-current="true"', $html);
|
||
self::assertStringContainsString('lang="uk"', $html);
|
||
|
||
// active option must be a <span>, not an <a>
|
||
self::assertMatchesRegularExpression(
|
||
'/<span class="locale-switcher__option is-current"[^>]*aria-current="true"[^>]*lang="uk"/',
|
||
$html,
|
||
);
|
||
}
|
||
|
||
#[Test]
|
||
public function inactiveLocalesAreLinksToLocaleController(): void
|
||
{
|
||
$html = (new LocaleSwitcher('de', '/foo/bar'))->render();
|
||
self::assertStringContainsString('href="/locale?set=en&return=%2Ffoo%2Fbar"', $html);
|
||
self::assertStringContainsString('href="/locale?set=uk&return=%2Ffoo%2Fbar"', $html);
|
||
self::assertStringContainsString('href="/locale?set=ru&return=%2Ffoo%2Fbar"', $html);
|
||
}
|
||
|
||
#[Test]
|
||
public function stripsQueryAndFragmentFromReturnPath(): void
|
||
{
|
||
$html = (new LocaleSwitcher('de', '/?lang=uk#kontakt'))->render();
|
||
// sanitisePath keeps only the path part
|
||
self::assertStringContainsString('return=%2F', $html);
|
||
self::assertStringNotContainsString('return=%2F%3Flang', $html);
|
||
self::assertStringNotContainsString('return=%2F%23kontakt', $html);
|
||
}
|
||
|
||
#[Test]
|
||
public function rejectsPathsThatDoNotStartWithSlash(): void
|
||
{
|
||
$html = (new LocaleSwitcher('de', 'https://evil.example/'))->render();
|
||
// sanitisePath falls back to '/'
|
||
self::assertStringContainsString('return=%2F', $html);
|
||
self::assertStringNotContainsString('evil.example', $html);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, array{string, string}>
|
||
*/
|
||
public static function flagDataProvider(): array
|
||
{
|
||
return [
|
||
'DE Germany' => ['de', 'de.svg'],
|
||
'EN en-GB' => ['en', 'gb.svg'],
|
||
'UK Ukraine' => ['uk', 'ua.svg'],
|
||
'RU Russia' => ['ru', 'ru.svg'],
|
||
];
|
||
}
|
||
|
||
#[Test]
|
||
public function flagImgReturnsValidImgForEverySupportedLocale(): void
|
||
{
|
||
foreach (Locale::SUPPORTED as $code) {
|
||
$img = LocaleSwitcher::flagImg($code);
|
||
self::assertStringStartsWith('<img', $img);
|
||
self::assertStringContainsString('class="flag"', $img);
|
||
self::assertStringContainsString('width="24" height="18"', $img);
|
||
self::assertStringContainsString('alt=""', $img);
|
||
self::assertStringEndsWith('>', $img);
|
||
}
|
||
}
|
||
|
||
#[Test]
|
||
public function flagImgHasFallbackForUnknownLocale(): void
|
||
{
|
||
$img = LocaleSwitcher::flagImg('xx');
|
||
self::assertStringStartsWith('<img', $img);
|
||
self::assertStringContainsString('class="flag"', $img);
|
||
// 1×1 transparent gif keeps the layout stable even when the
|
||
// locale code is not one of our four.
|
||
self::assertStringContainsString('data:image/gif', $img);
|
||
}
|
||
|
||
#[Test]
|
||
public function ariaLabelUsesCurrentLocaleName(): void
|
||
{
|
||
$htmlDe = (new LocaleSwitcher('de', '/'))->render();
|
||
$htmlEn = (new LocaleSwitcher('en', '/'))->render();
|
||
|
||
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
|
||
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
|
||
}
|
||
|
||
#[Test]
|
||
public function triggerContainsCurrentLocaleFlag(): void
|
||
{
|
||
// The closed dropdown shows the current locale's flag in the trigger
|
||
$html = (new LocaleSwitcher('de', '/'))->render();
|
||
// The first <img class="flag"> in the document is the trigger and it
|
||
// must point at the German flag asset under /img/flags/.
|
||
$deFlag = LocaleSwitcher::flagImg('de');
|
||
$pos = strpos($html, $deFlag);
|
||
self::assertNotFalse($pos, 'expected German flag <img> in the trigger (first <img class="flag"> in document)');
|
||
self::assertStringContainsString('src="/img/flags/de.svg"', $deFlag);
|
||
}
|
||
}
|