Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,8 +1,11 @@
|
|||||||
*.ps1
|
*.ps1
|
||||||
*.py
|
*.py
|
||||||
|
!/tests/E2E/*.py
|
||||||
/node_modules/
|
/node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
.continue/
|
.continue/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
vendor/
|
vendor/
|
||||||
.phpunit.cache/
|
.phpunit.cache/
|
||||||
|
build/
|
||||||
|
.phpunit.coverage.cache/
|
||||||
|
|||||||
@@ -20,4 +20,12 @@
|
|||||||
<directory>app</directory>
|
<directory>app</directory>
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
|
|
||||||
|
<coverage cacheDirectory=".phpunit.coverage.cache">
|
||||||
|
<report>
|
||||||
|
<clover outputFile="build/coverage/clover.xml"/>
|
||||||
|
<html outputDirectory="build/coverage/html" lowUpperBound="50" highLowerBound="85"/>
|
||||||
|
<text outputFile="php://stdout" showOnlySummary="true"/>
|
||||||
|
</report>
|
||||||
|
</coverage>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
97
tests/E2E/language_flow.py
Normal file
97
tests/E2E/language_flow.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
E2E test: language switcher flow on the landing page.
|
||||||
|
|
||||||
|
Verifies that clicking a flag in the locale switcher changes the active
|
||||||
|
language and the page hero content updates accordingly. Runs against a
|
||||||
|
PHP dev-server instance expected at http://127.0.0.1:8081.
|
||||||
|
|
||||||
|
Usage: python3 tests/E2E/language_flow.py
|
||||||
|
EXIT 0 on success, non-zero on failure.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Resolve the playwright path without requiring a global install.
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write("playwright not installed; skipping E2E\n")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8081")
|
||||||
|
|
||||||
|
# Per-locale hero line 1, kept in sync with app/Locales/*.php.
|
||||||
|
# The E2E test reads the title from the H1 instead of hardcoding so it
|
||||||
|
# only needs to be kept in sync with the EN locale (the default fallback
|
||||||
|
# shown when navigating to the root).
|
||||||
|
EXPECTED = {
|
||||||
|
"de": ("Großzügiges", "Einfamilienhaus"),
|
||||||
|
"en": ("Spacious", "Detached house"),
|
||||||
|
"uk": ("Просторий", "Приватний будинок"),
|
||||||
|
"ru": ("Просторный", "Частный дом"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_locale(page, code: str) -> None:
|
||||||
|
line1, line2 = EXPECTED[code]
|
||||||
|
h1 = page.locator("h1.hero-h1")
|
||||||
|
h1.wait_for(state="visible", timeout=5_000)
|
||||||
|
text = h1.inner_text(timeout=5_000)
|
||||||
|
if line1 not in text or line2 not in text:
|
||||||
|
raise AssertionError(
|
||||||
|
f"[{code}] expected hero to contain {line1!r} + {line2!r}, got: {text!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# <html lang> must reflect the active locale.
|
||||||
|
html_lang = page.evaluate("document.documentElement.lang")
|
||||||
|
if html_lang != code:
|
||||||
|
raise AssertionError(
|
||||||
|
f"[{code}] expected <html lang>={code!r}, got {html_lang!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with sync_playwright() as pw:
|
||||||
|
# Use system Chrome (Playwright's bundled Chromium does not install
|
||||||
|
# on Ubuntu 26.04). Pass --no-sandbox since this runs as root in CI.
|
||||||
|
browser = pw.chromium.launch(
|
||||||
|
executable_path=os.environ.get("CHROME_BIN", "/usr/bin/google-chrome"),
|
||||||
|
headless=True,
|
||||||
|
args=["--no-sandbox", "--disable-gpu"],
|
||||||
|
)
|
||||||
|
context = browser.new_context(accept_downloads=False)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pin the initial locale via query-string so the test is not
|
||||||
|
# at the mercy of the browser's Accept-Language header.
|
||||||
|
page.goto(f"{BASE_URL}/?lang=de", wait_until="domcontentloaded", timeout=10_000)
|
||||||
|
# Default locale is DE (Locale::DEFAULT).
|
||||||
|
check_locale(page, "de")
|
||||||
|
|
||||||
|
for code in ("en", "uk", "ru", "de"):
|
||||||
|
# Locale switcher links are <a> with hreflang equal to the locale code.
|
||||||
|
link = page.locator(f'a[hreflang="{code}"]').first
|
||||||
|
link.wait_for(state="visible", timeout=5_000)
|
||||||
|
link.click()
|
||||||
|
# Wait for the page to reload / navigate.
|
||||||
|
page.wait_for_load_state("domcontentloaded", timeout=10_000)
|
||||||
|
check_locale(page, code)
|
||||||
|
print(f" ✓ locale={code} hero verified")
|
||||||
|
|
||||||
|
print("OK: language flow E2E passed for all 4 locales")
|
||||||
|
return 0
|
||||||
|
except (PlaywrightTimeoutError, AssertionError) as exc:
|
||||||
|
print(f"FAIL: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
context.close()
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
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