*/ private array $translations; /** @var array */ 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 $arr * @return array */ 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 $formErrors * @param array> $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(); } }