- Nav: always visible glass background (no more transparent-on-top) - Logo: remove text span, kill link underline completely - Masonry: fix HTML class drift (gallery-grid -> masonry-grid) - Gallery captions: rename to grid-item-label (hover-only) - Honeypot: rename to hp-field (was rendered visible!) - Hero: stronger gradient + text-shadow on h1/tag/meta - LAGE features: cards with pin icon, no more bulleted list - Map: full-viewport-width break-out from .lage-section - Contact form: border-radius, focus glow, custom select arrow, working .form-submit button style - Light text: unified --text-muted-on-dark token (replaces 4 magic white-XX% variants + --stone on .fact-label) - A11y test: update honeypot class assertion
203 lines
7.0 KiB
PHP
203 lines
7.0 KiB
PHP
<?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="hp-field"', $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();
|
|
}
|
|
}
|