feat(i18n): accessibility - per-field form errors, landmark aria-labels, tests (closes #76)
This commit is contained in:
202
tests/Views/HomeViewA11yTest.php
Normal file
202
tests/Views/HomeViewA11yTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user