feat(i18n): accessibility - per-field form errors, landmark aria-labels, tests (closes #76)

This commit is contained in:
Hermes
2026-06-04 11:04:06 +00:00
parent 0186de90ec
commit 13a25aded2
10 changed files with 421 additions and 26 deletions

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Tests\Views;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration test: render the full home view with various
* form-error scenarios and verify the emitted HTML exposes
* accessibility hooks (aria-invalid, aria-describedby,
* role/aria-live, etc.).
*
* Strategy: include the view file directly after populating the
* locals it expects. We bypass the controller and session start
* to keep the test self-contained.
*/
final class HomeViewA11yTest extends TestCase
{
/** @var array<string, string> */
private array $translations;
/** @var array<string, mixed> */
private array $session = [];
protected function setUp(): void
{
// Use the real de.php translations so we never drift from
// production copy. Anything missing returns the key in
// brackets (matches I18n::t()'s dev fallback).
$de = require dirname(__DIR__, 2) . '/app/Locales/de.php';
$this->translations = $this->flatten($de);
$this->session = [
'csrf_token' => str_repeat('a', 64),
];
}
/**
* Flatten a nested translation array to dot.notation keys.
*
* @param array<string,mixed> $arr
* @return array<string,string>
*/
private function flatten(array $arr, string $prefix = ''): array
{
$out = [];
foreach ($arr as $key => $val) {
$full = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (is_array($val)) {
$out += $this->flatten($val, $full);
} else {
$out[$full] = (string) $val;
}
}
return $out;
}
#[Test]
public function formResultHasAriaLivePolite(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('id="form-result"', $html);
self::assertStringContainsString('aria-live="polite"', $html);
self::assertStringContainsString('role="status"', $html);
}
#[Test]
public function errorListHasRoleAlert(): void
{
$html = $this->renderHomeView(
formErrors: ['form.error.csrf'],
formFieldErrors: [],
formSuccess: false,
);
self::assertStringContainsString('class="form-errors"', $html);
self::assertStringContainsString('role="alert"', $html);
}
#[Test]
public function fieldErrorRendersAriaInvalidAndDescribedBy(): void
{
$html = $this->renderHomeView(
formErrors: [],
formFieldErrors: [
'fname' => ['form.error.fname_required'],
'email' => ['form.error.email_invalid'],
'message' => ['form.error.message_required'],
],
formSuccess: false,
);
self::assertStringContainsString('aria-invalid="true"', $html);
self::assertStringContainsString('aria-describedby="err-fname"', $html);
self::assertStringContainsString('aria-describedby="err-email"', $html);
self::assertStringContainsString('aria-describedby="err-message"', $html);
self::assertStringContainsString('id="err-fname" class="form-field-error"', $html);
self::assertStringContainsString('Bitte geben Sie Ihren Vornamen an.', $html);
self::assertStringContainsString('id="err-email" class="form-field-error"', $html);
self::assertStringContainsString('Bitte geben Sie eine gültige E-Mail-Adresse an.', $html);
self::assertStringContainsString('id="err-message" class="form-field-error"', $html);
}
#[Test]
public function fieldsWithoutErrorsDoNotHaveAriaInvalid(): void
{
$html = $this->renderHomeView(
formErrors: [],
formFieldErrors: ['fname' => ['form.error.fname_required']],
formSuccess: false,
);
$count = substr_count($html, 'aria-invalid="true"');
self::assertSame(1, $count, 'Only fields with errors should carry aria-invalid');
self::assertStringNotContainsString('aria-describedby="err-lname"', $html);
self::assertStringNotContainsString('aria-describedby="err-email"', $html);
self::assertStringNotContainsString('aria-describedby="err-message"', $html);
}
#[Test]
public function successMessageRendersInsideFormResult(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: true);
self::assertStringContainsString('class="form-success"', $html);
self::assertStringContainsString('Vielen Dank für Ihre Anfrage!', $html);
self::assertStringContainsString('Wir haben Ihre Nachricht erhalten', $html);
}
#[Test]
public function honeypotFieldIsVisuallyHidden(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('class="form-hp"', $html);
self::assertStringContainsString('aria-hidden="true"', $html);
self::assertStringContainsString('tabindex="-1"', $html);
self::assertStringContainsString('autocomplete="off"', $html);
}
#[Test]
public function csrfTokenIsEmbeddedAsHiddenField(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('name="csrf_token"', $html);
self::assertStringContainsString('value="' . str_repeat('a', 64) . '"', $html);
}
#[Test]
public function submitButtonHasExpectedClass(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('class="form-submit"', $html);
}
/**
* @param list<string> $formErrors
* @param array<string, list<string>> $formFieldErrors
*/
private function renderHomeView(array $formErrors, array $formFieldErrors, bool $formSuccess): string
{
$locale = 'de';
$t = function (string $key) use ($locale): string {
return $this->translations[$key] ?? '[' . $key . ']';
};
$formData = [
'fname' => 'Maria',
'lname' => '',
'email' => 'not-an-email',
'phone' => '',
'interest' => 'visit',
'message' => '',
];
$escapeContactValue = static fn(string $value): string
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
// Provide $_SESSION to the view scope via a global.
$_SESSION = $this->session;
$viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
self::assertFileExists($viewPath);
ob_start();
try {
include $viewPath;
} catch (\Throwable $e) {
ob_end_clean();
self::fail('View rendering threw: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
}
return (string) ob_get_clean();
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Views;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Static a11y checks for the shared layout
* (`app/views/layouts/main.php`). We verify the layout's *source*
* carries the right ARIA / landmark attributes rather than spinning
* up the full render (which would need nav, OG, structured-data,
* server-vars, and a dozen more locals). The home view is tested
* for content-level a11y in `HomeViewA11yTest`.
*/
final class LayoutA11yTest extends TestCase
{
private string $layoutSource;
protected function setUp(): void
{
$path = dirname(__DIR__, 2) . '/app/views/layouts/main.php';
self::assertFileExists($path);
$this->layoutSource = (string) file_get_contents($path);
self::assertNotEmpty($this->layoutSource);
}
#[Test]
public function htmlRootCarriesLangAttribute(): void
{
// The layout emits <html lang="..."> with the value rendered by PHP from
// the $locale variable (not literally `$locale` between quotes).
// We assert the source binds lang to the $locale variable inside PHP output.
self::assertStringContainsString('<html lang="', $this->layoutSource);
self::assertStringContainsString('$locale', $this->layoutSource);
// Make sure $locale actually flows into the lang attribute (basic
// co-occurrence check on the same <html ...> opening tag).
self::assertMatchesRegularExpression(
'~<html\b[^>]*\blang=[^>]*\$locale~s',
$this->layoutSource,
'Layout must bind <html lang> to the current locale'
);
}
#[Test]
public function skipLinkPointsAtMain(): void
{
self::assertStringContainsString('class="skip-link"', $this->layoutSource);
self::assertStringContainsString('href="#main"', $this->layoutSource);
}
#[Test]
public function mainLandmarkHasIdAndAriaLabel(): void
{
self::assertMatchesRegularExpression(
'/<main\b[^>]*\bid="main"/',
$this->layoutSource
);
self::assertMatchesRegularExpression(
'/<main\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function navLandmarkHasAriaLabel(): void
{
self::assertMatchesRegularExpression(
'/<nav\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function footerHasRoleAndLabel(): void
{
self::assertMatchesRegularExpression(
'/<footer\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function lightboxIsADialogModal(): void
{
self::assertStringContainsString('id="lightbox"', $this->layoutSource);
self::assertStringContainsString('role="dialog"', $this->layoutSource);
self::assertStringContainsString('aria-modal="true"', $this->layoutSource);
}
#[Test]
public function lightboxImageCarriesAltAttribute(): void
{
self::assertMatchesRegularExpression(
'/<img\b[^>]*\bid="lightboxImg"[^>]*\balt=/',
$this->layoutSource
);
}
#[Test]
public function localeSwitcherHostIsAriaLabeled(): void
{
// The LocaleSwitcher widget uses aria-label on its <ul>.
self::assertStringContainsString('aria-label="', $this->layoutSource);
}
#[Test]
public function mobileNavToggleIsKeyboardAccessible(): void
{
self::assertMatchesRegularExpression(
'/<button\b[^>]*\bclass="nav-hamburger"/',
$this->layoutSource
);
self::assertStringContainsString('aria-expanded=', $this->layoutSource);
self::assertStringContainsString('aria-controls=', $this->layoutSource);
}
}