OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

AccessibilityTest.php

7 KB
<?php

declare(strict_types=1);

use OpenNIT\Core\AccessibilityScan;

/**
 * Hält die Barrierefreiheit der Vorlagen fest.
 *
 * Drei Regeln stehen auf null und müssen dort bleiben: Jedes Bedienelement
 * trägt einen Namen, jedes Bild einen Alternativtext, jeder Tabellenkopf eine
 * Richtung. Für die vierte – Eingaben ohne Beschriftung – gilt eine Obergrenze,
 * die nur fallen darf: So bleibt der Rest sichtbar, ohne dass eine neue Vorlage
 * ihn stillschweigend vergrößert.
 *
 * Geprüft wird der Quelltext. Tastaturbedienung, Fokusreihenfolge und
 * Vorlese-Software lassen sich damit nicht prüfen – das bleibt ein Durchgang
 * im Browser.
 */
return static function (): void {

    /** Verbleibende Eingaben ohne Beschriftung. Diese Zahl darf nur sinken. */
    $obergrenze = 262;

    $vorlagen = static function (): array {
        $dateien = [];
        $lauf = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator(__DIR__ . '/../templates', FilesystemIterator::SKIP_DOTS)
        );
        foreach ($lauf as $datei) {
            if ($datei->isFile() && $datei->getExtension() === 'php') {
                $dateien[] = $datei->getPathname();
            }
        }
        sort($dateien);
        return $dateien;
    };

    $funde = [];
    foreach ($vorlagen() as $datei) {
        foreach (AccessibilityScan::pruefe((string)file_get_contents($datei)) as $fund) {
            $funde[$fund['regel']][] = basename($datei) . ':' . $fund['zeile'] . ' ' . $fund['fund'];
        }
    }

    // ----------------------------------------------------------------
    // Regeln ohne Ausnahme
    // ----------------------------------------------------------------

    test('Jedes Bedienelement trägt einen Namen', function () use ($funde) {
        $offen = $funde['bedienelement-ohne-namen'] ?? [];
        assertSame([], $offen, 'Schaltfläche oder Verweis ohne aria-label/title und ohne Text');
    });

    test('Jedes Bild trägt einen Alternativtext', function () use ($funde) {
        $offen = $funde['bild-ohne-alternativtext'] ?? [];
        assertSame([], $offen, 'alt fehlt – bei Zierbildern gehört alt="" hin');
    });

    test('Jeder Tabellenkopf nennt seine Richtung', function () use ($funde) {
        $offen = $funde['tabellenkopf-ohne-richtung'] ?? [];
        assertSame([], $offen, 'th ohne scope="col"/"row"');
    });

    test('Eingaben ohne Beschriftung werden nicht mehr', function () use ($funde, $obergrenze) {
        $anzahl = count($funde['eingabe-ohne-beschriftung'] ?? []);
        assertTrue(
            $anzahl <= $obergrenze,
            "Eingaben ohne Beschriftung: {$anzahl}, erlaubt sind höchstens {$obergrenze}"
        );
    });

    // ----------------------------------------------------------------
    // Der Rahmen
    // ----------------------------------------------------------------

    test('Beide Layouts führen zum Inhalt und benennen ihre Bereiche', function () {
        foreach (['main', 'admin'] as $layout) {
            $quelle = (string)file_get_contents(__DIR__ . '/../templates/layouts/' . $layout . '.php');

            assertTrue(str_contains($quelle, 'class="skip-link" href="#main-content"'), $layout . ': Sprungmarke fehlt');
            assertTrue(str_contains($quelle, 'id="main-content" tabindex="-1"'), $layout . ': Ziel der Sprungmarke fehlt');
            assertTrue(
                preg_match('/<nav\b(?![^>]*aria-label)/', $quelle) !== 1,
                $layout . ': Ein <nav> ohne aria-label'
            );
            assertTrue(str_contains($quelle, '/assets/js/dialog.js'), $layout . ': dialog.js nicht eingebunden');
        }
    });

    test('Der Tastaturfokus ist zentral sichtbar gemacht', function () {
        $css = (string)file_get_contents(__DIR__ . '/../public/assets/css/app.css');

        assertTrue(str_contains($css, ':focus-visible {'), 'Keine Regel für :focus-visible');
        assertTrue(str_contains($css, 'var(--focus-ring-color)'), 'Der Ring nutzt kein Token');
        assertTrue(str_contains($css, '@media (prefers-reduced-motion: reduce)'), 'Bewegung lässt sich nicht abbestellen');
        assertTrue(str_contains($css, '@media (forced-colors: active)'), 'Kein Ring im Kontrastmodus');
        assertTrue(str_contains($css, '.visually-hidden'), 'Keine Klasse für Text nur für Vorlese-Software');

        $tokens = (string)file_get_contents(__DIR__ . '/../public/assets/css/tokens.css');
        foreach (['--focus-ring-color', '--focus-ring-contrast', '--focus-ring-width', '--focus-ring-offset'] as $token) {
            assertTrue(str_contains($tokens, $token), 'Token fehlt: ' . $token);
        }
    });

    test('Dialoge sind mit der Tastatur bedienbar', function () {
        $js = (string)file_get_contents(__DIR__ . '/../public/assets/js/dialog.js');

        // Fokusfalle, Rücksprung, Escape und die Ansage als Dialog.
        assertTrue(str_contains($js, "e.key === 'Escape'"), 'Escape schließt nicht');
        assertTrue(str_contains($js, "e.key !== 'Tab'"), 'Keine Fokusfalle für Tabulator');
        assertTrue(str_contains($js, "setAttribute('aria-modal', 'true')"), 'Der Dialog meldet sich nicht als solcher');
        assertTrue(str_contains($js, 'aria-labelledby'), 'Der Dialog bekommt keinen Namen');
        assertTrue(str_contains($js, 'eintrag.herkunft'), 'Der Fokus kehrt nicht zurück');
        assertTrue(str_contains($js, 'MutationObserver'), 'Die Sichtbarkeit wird nicht beobachtet');
    });

    // ----------------------------------------------------------------
    // Die Prüfung selbst
    // ----------------------------------------------------------------

    test('Ein Text, den PHP einsetzt, gilt als Beschriftung', function () {
        // Sonst gälte jede Schaltfläche mit übersetztem Text als namenlos.
        $quelle = '<button class="x"><?= $this->te(\'Speichern\') ?></button>';
        assertSame([], AccessibilityScan::pruefe($quelle));
    });

    test('Ein Kreuz allein ist kein Name', function () {
        $funde = AccessibilityScan::pruefe('<button class="modal-close">&times;</button>');
        assertSame('bedienelement-ohne-namen', $funde[0]['regel'] ?? '');
    });

    test('Eine Seitenzahl ist ein Name', function () {
        assertSame([], AccessibilityScan::pruefe('<a href="?page=2">2</a>'));
    });

    test('Ein umschließendes label beschriftet die Eingabe', function () {
        $mitHuelle = '<label>Aktiv <input type="checkbox" name="a"></label>';
        assertSame([], AccessibilityScan::pruefe($mitHuelle));

        $ohne = '<span>Aktiv</span> <input type="checkbox" name="a">';
        assertSame('eingabe-ohne-beschriftung', AccessibilityScan::pruefe($ohne)[0]['regel'] ?? '');
    });

    test('Ein label mit for bindet über die id', function () {
        $gebunden = '<label for="n">Name</label><input id="n" name="n">';
        assertSame([], AccessibilityScan::pruefe($gebunden));

        $lose = '<label>Name</label><input id="n" name="n">';
        assertSame('eingabe-ohne-beschriftung', AccessibilityScan::pruefe($lose)[0]['regel'] ?? '');
    });

    test('Skripte und Stile werden nicht geprüft', function () {
        $imSkript = '<script>var x = \'<th>\' + \'<img src=a>\';</script>';
        assertSame([], AccessibilityScan::pruefe($imSkript));
    });

    test('Versteckte und absendende Felder brauchen keine Beschriftung', function () {
        foreach (['hidden', 'submit', 'button', 'reset', 'image'] as $art) {
            assertSame([], AccessibilityScan::pruefe('<input type="' . $art . '" name="a">'), $art);
        }
    });
};