test(i18n): integration render tests for 4 locales + Playwright E2E flow (closes #77)
This commit is contained in:
223
tests/Integration/RenderTest.php
Normal file
223
tests/Integration/RenderTest.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use App\Controllers\HomeController;
|
||||
use App\Controllers\LocaleSwitcher;
|
||||
use App\Core\I18n;
|
||||
use App\Core\Locale;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Integration test: render the home view in every supported locale and
|
||||
* assert the rendered HTML matches the locale's translation strings.
|
||||
*
|
||||
* Acts as a render-snapshot smoke test for the i18n feature and a
|
||||
* regression guard against hardcoded DE strings slipping into
|
||||
* non-DE views.
|
||||
*/
|
||||
final class RenderTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Build the local variable scope expected by `app/views/home/index.php`
|
||||
* and the layout, and capture the rendered output.
|
||||
*
|
||||
* @return array{0:string,1:string} [html, rendered <html lang> value]
|
||||
*/
|
||||
private function renderHomeIn(string $locale, array $formErrors = [], array $formFieldErrors = [], bool $formSuccess = false): array
|
||||
{
|
||||
// Sanity: the locale must be one of the supported ones.
|
||||
self::assertTrue(Locale::isSupported($locale), "Unsupported locale for render: $locale");
|
||||
|
||||
$t = static function (string $key) use ($locale): string {
|
||||
return I18n::t($key, [], $locale);
|
||||
};
|
||||
|
||||
$formData = [
|
||||
'fname' => '',
|
||||
'lname' => '',
|
||||
'email' => '',
|
||||
'phone' => '',
|
||||
'interest' => 'visit',
|
||||
'message' => '',
|
||||
];
|
||||
|
||||
$interestKeys = [
|
||||
'visit' => 'form.interest.visit',
|
||||
'info' => 'form.interest.info',
|
||||
'apply' => 'form.interest.apply',
|
||||
];
|
||||
|
||||
$escapeContactValue = static fn(string $value): string
|
||||
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Empty honeypot/csrf/session.
|
||||
$_SESSION = [
|
||||
'csrf_token' => 'csrf-test-token',
|
||||
'form_start_time' => time(),
|
||||
];
|
||||
|
||||
$viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
|
||||
self::assertFileExists($viewPath);
|
||||
|
||||
ob_start();
|
||||
include $viewPath;
|
||||
$body = (string) ob_get_clean();
|
||||
|
||||
// Layout prefix: open <html lang>...</html> via a tiny shim.
|
||||
$layout = sprintf(
|
||||
'<!doctype html><html lang="%s"><head><meta charset="utf-8"><title>%s</title></head><body>%s</body></html>',
|
||||
htmlspecialchars($locale, ENT_QUOTES),
|
||||
htmlspecialchars($t('hero.h1.line1') . ' ' . $t('hero.h1.line2'), ENT_QUOTES),
|
||||
$body,
|
||||
);
|
||||
|
||||
return [$layout, $locale];
|
||||
}
|
||||
|
||||
/**
|
||||
* Each locale's home render must contain the locale-specific
|
||||
* translation keys (and they must be the locale's expected text).
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('supportedLocaleProvider')]
|
||||
public function homeRendersLocaleSpecificHeroCopy(string $locale): void
|
||||
{
|
||||
[$html] = $this->renderHomeIn($locale);
|
||||
|
||||
// Per-locale hero h1 line 1 — strong, locale-specific token.
|
||||
$expectedLine1 = I18n::t('hero.h1.line1', [], $locale);
|
||||
self::assertNotSame('hero.h1.line1', $expectedLine1, "Translation missing for hero.h1.line1 in $locale");
|
||||
self::assertStringContainsString(
|
||||
$expectedLine1,
|
||||
$html,
|
||||
"Expected hero line 1 ($expectedLine1) not rendered in $locale"
|
||||
);
|
||||
|
||||
// Hero tag
|
||||
$expectedTag = I18n::t('hero.tag', [], $locale);
|
||||
self::assertStringContainsString($expectedTag, $html, "Hero tag not rendered in $locale");
|
||||
}
|
||||
|
||||
/**
|
||||
* `<html lang="…">` must match the active locale so screen readers,
|
||||
* search engines, and the browser's auto-translate UI behave.
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('supportedLocaleProvider')]
|
||||
public function htmlRootLangAttributeMatchesActiveLocale(string $locale): void
|
||||
{
|
||||
[$html] = $this->renderHomeIn($locale);
|
||||
self::assertMatchesRegularExpression(
|
||||
'~<html\s+lang="[^"]*' . preg_quote($locale, '~') . '[^"]*"~',
|
||||
$html,
|
||||
"Layout must bind <html lang> to $locale"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard: a hardcoded DE string that's not a proper noun
|
||||
* (e.g. "Einfamilienhaus") must NOT appear in the EN/UK/RU render.
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('nonGermanLocaleProvider')]
|
||||
public function nonGermanRenderDoesNotLeakGermanCopy(string $locale): void
|
||||
{
|
||||
[$html] = $this->renderHomeIn($locale);
|
||||
|
||||
$germanOnly = [
|
||||
'Einfamilienhaus', // hero.h1.line2 DE
|
||||
'Großzügiges', // hero.h1.line1 DE (with ß)
|
||||
'Entdecken', // hero.discover DE
|
||||
'Galerie', // nav.gallery DE
|
||||
];
|
||||
|
||||
foreach ($germanOnly as $needle) {
|
||||
self::assertStringNotContainsString(
|
||||
$needle,
|
||||
$html,
|
||||
"German copy \"$needle\" leaked into $locale render"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switcher widget: with the active locale, that locale's flag/link
|
||||
* must carry `aria-current="true"` per a11y contract.
|
||||
*/
|
||||
#[Test]
|
||||
#[DataProvider('supportedLocaleProvider')]
|
||||
public function localeSwitcherMarksActiveLocaleWithAriaCurrent(string $locale): void
|
||||
{
|
||||
$switcherHtml = (new LocaleSwitcher($locale, '/'))->render();
|
||||
|
||||
self::assertStringContainsString('aria-current="true"', $switcherHtml, "Active locale $locale should have aria-current");
|
||||
|
||||
// The active locale's link must point at itself (relative path stays on the page).
|
||||
self::assertMatchesRegularExpression(
|
||||
'~aria-current="true"[^>]*>.*?'
|
||||
. preg_quote(I18n::t('locale.' . $locale, [], $locale), '~')
|
||||
. '~s',
|
||||
$switcherHtml,
|
||||
"Active locale $locale not properly labelled in switcher"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All four locales should produce roughly the same DOM skeleton
|
||||
* (same section ids, same form structure) — translation is
|
||||
* content swap, not structural drift.
|
||||
*/
|
||||
#[Test]
|
||||
public function homeDomSkeletonIsStableAcrossLocales(): void
|
||||
{
|
||||
$skeletons = [];
|
||||
foreach (Locale::SUPPORTED as $locale) {
|
||||
[$html] = $this->renderHomeIn($locale);
|
||||
// Pull out section/landmark ids + a couple of structural tags.
|
||||
preg_match_all(
|
||||
'~<(?:section|main|header|footer|nav|aside|form)\b[^>]*(?:\bid="([^"]+)")?~',
|
||||
$html,
|
||||
$matches
|
||||
);
|
||||
$skeletons[$locale] = $matches[0];
|
||||
}
|
||||
|
||||
// All four skeletons must have the same number of structural tags.
|
||||
$counts = array_map('count', $skeletons);
|
||||
$unique = array_unique($counts);
|
||||
self::assertCount(1, $unique, 'DOM skeleton size differs across locales: ' . json_encode($counts));
|
||||
}
|
||||
|
||||
public static function supportedLocaleProvider(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (Locale::SUPPORTED as $locale) {
|
||||
$out[$locale] = [$locale];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function nonGermanLocaleProvider(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (Locale::SUPPORTED as $locale) {
|
||||
if ($locale === Locale::DEFAULT) {
|
||||
continue;
|
||||
}
|
||||
$out[$locale] = [$locale];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// Make sure the I18n cache is fresh per test.
|
||||
I18n::flushCache();
|
||||
parent::setUp();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user