diff --git a/bin/a11y b/bin/a11y index 8be8b56..84d2e04 100644 --- a/bin/a11y +++ b/bin/a11y @@ -18,7 +18,7 @@ use YakNet\AccessibilityConsole\Console\Command\InitCommand; use YakNet\AccessibilityConsole\Console\Command\RulesCommand; $container = ContainerFactory::create(); -$application = new Application('YakNet Accessibility Console', '3.1.2'); +$application = new Application('YakNet Accessibility Console', '3.2.0'); // Add commands resolved via DI Container $application->addCommand($container->get(ScanCommand::class)); diff --git a/composer.json b/composer.json index f64bf70..1114cb6 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "yaknet" ], "homepage": "https://forum.yak.net.tr", - "version": "3.1.4", + "version": "3.2.0", "type": "library", "license": "MIT", "authors": [ @@ -39,7 +39,11 @@ "sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0", "php-di/php-di": "^7.1", "symfony/css-selector": "^8.1", - "symfony/dom-crawler": "^8.1" + "symfony/dom-crawler": "^8.1", + "spatie/color": "^1.5", + "intervention/image": "^3.0", + "dompdf/dompdf": "^3.0", + "phpoffice/phpspreadsheet": "^2.0 || ^3.0" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/dashboard/public/index.html b/dashboard/public/index.html new file mode 100644 index 0000000..5cad2c4 --- /dev/null +++ b/dashboard/public/index.html @@ -0,0 +1,103 @@ + + + + + + YakNet Accessibility Console - Live Web Dashboard + + + + + +
+
+
♿
+
+

+ YakNet Accessibility Engine + v3.2 Enterprise +

+

Gerçek Zamanlı Canlı Web Dashboard & WCAG 2.1 İzleyici

+
+
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+ + +
+
+
+
Erişilebilirlik Skoru
+
100/100
+
WCAG 2.1 AA Standartı
+
+
+ + + + +
+
+ +
+
Toplam İhlal
+
0
+
Sıfır Kritik Hata
+
+ +
+
Aktif Kurallar
+
110
+
Otomatik WCAG Motoru
+
+ +
+
Yapay Zeka Onarımı
+
Aktif
+
Gemini AI AutoHeal Motoru
+
+
+ + +
+

📋 Tespit Edilen WCAG İhlalleri

+
+ Henüz tarama başlatılmadı veya sitede ihlal bulunmadı. +
+
+
+ + + + diff --git a/dashboard/server.php b/dashboard/server.php new file mode 100644 index 0000000..36ac026 --- /dev/null +++ b/dashboard/server.php @@ -0,0 +1,23 @@ +web(append: [ + AccessibilityMiddleware::class, +]); +``` + +### Automated PHPUnit / Pest Testing +```php +use YakNet\AccessibilityConsole\Stubs\Laravel\AssertsAccessibility; + +class ExampleTest extends TestCase +{ + use AssertsAccessibility; + + public function test_homepage_is_accessible(): void + { + $this->assertPageIsAccessible('https://localhost', minScore: 90); + } +} +``` + +## 2. WordPress Integration + +Drop `stubs/wordpress/yaknet-a11y-wp-plugin.php` into your `wp-content/plugins/` folder to get live admin bar WCAG badges and automatic scanning. diff --git a/docs/RULES.md b/docs/RULES.md new file mode 100644 index 0000000..8598f19 --- /dev/null +++ b/docs/RULES.md @@ -0,0 +1,25 @@ +# ♿ YakNet Accessibility Console — WCAG 2.1 Rules Reference + +YakNet Accessibility Console features **110 automated WCAG 2.1 rules** across Levels A, AA, and AAA. + +## Rule Categories + +### 1. ARIA & Roles (`src/Rules/Aria/`) +- `WCAG_1_3_1_ARIA_REQUIRED_CHILDREN`: Ensures ARIA parent roles contain required child roles (e.g. `role="radiogroup"`). +- `WCAG_4_1_2_ARIA_TOGGLE_PRESSED`: Validates `aria-pressed` states on custom toggle buttons. + +### 2. Form & Controls (`src/Rules/Form/`) +- `WCAG_1_3_5_AUTOCOMPLETE`: Validates HTML5 autocomplete attributes on personal input fields. +- `WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE`: Ensures password visibility buttons have accessible names. + +### 3. Color & Vision (`src/Rules/Color/`) +- `WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST`: Simulates Deuteranopia and Protanopia vision deficiencies to verify contrast. +- `WCAG_1_4_3_CONTRAST`: Verifies minimum 4.5:1 text-to-background contrast ratio. + +### 4. Landmarks & Structure (`src/Rules/Landmark/`) +- `WCAG_1_3_1_LandmarkMain`: Enforces a single `
` landmark element per document. +- `WCAG_1_3_1_CONTENTINFO_PARENT`: Ensures `footer` contentinfo landmark is placed at top level. + +### 5. Media & Images (`src/Rules/Image/`) +- `WCAG_1_1_1_SVG`: Ensures SVG graphics contain alternative text or `aria-hidden="true"`. +- `WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE`: Audits layout shift and touch target sizes (minimum 24x24px). diff --git a/src/Audit/SiteCrawler.php b/src/Audit/SiteCrawler.php new file mode 100644 index 0000000..4fafdeb --- /dev/null +++ b/src/Audit/SiteCrawler.php @@ -0,0 +1,76 @@ +scanner = $scanner ?? new Scanner(); + } + + /** + * Crawl and scan multiple pages of a website up to a maximum page limit. + * + * @param string $baseUrl Base URL to start crawling + * @param int $maxPages Maximum number of pages to audit + * @return array Aggregated site-wide audit results + */ + public function crawlAndAudit(string $baseUrl, int $maxPages = 5): array + { + /** @var array $visited */ + $visited = []; + $queue = [$baseUrl]; + /** @var array $results */ + $results = []; + + while (!empty($queue) && count($visited) < $maxPages) { + $url = array_shift($queue); + if (empty($url) || isset($visited[$url])) { + continue; + } + + $visited[$url] = true; + try { + $html = @file_get_contents($url); + $violations = $html !== false ? $this->scanner->scan($html) : []; + $score = max(0, 100 - count($violations) * 3); + + $results[$url] = [ + 'score' => $score, + 'violations_count' => count($violations), + 'violations' => $violations, + ]; + } catch (\Throwable $e) { + $results[$url] = ['error' => $e->getMessage(), 'score' => 0]; + } + } + + $scores = []; + $totalViolations = 0; + foreach ($results as $res) { + if (is_array($res)) { + if (isset($res['score']) && is_numeric($res['score'])) { + $scores[] = intval($res['score']); + } + if (isset($res['violations_count']) && is_numeric($res['violations_count'])) { + $totalViolations += intval($res['violations_count']); + } + } + } + + $avgScore = count($scores) > 0 ? (int)round(array_sum($scores) / count($scores)) : 0; + + return [ + 'base_url' => $baseUrl, + 'pages_audited' => count($visited), + 'average_score' => $avgScore, + 'total_violations' => $totalViolations, + 'page_results' => $results, + ]; + } +} diff --git a/src/AutoFix/HtmlRepairEngine.php b/src/AutoFix/HtmlRepairEngine.php new file mode 100644 index 0000000..14e0710 --- /dev/null +++ b/src/AutoFix/HtmlRepairEngine.php @@ -0,0 +1,49 @@ +} + */ + public static function autoRepair(string $html): array + { + $fixes = []; + $repaired = $html; + + // 1. Ensure attribute + if (preg_match('/]*\blang=)[^>]*>/i', $repaired)) { + $repaired = preg_replace('/]*)>/i', '', $repaired) ?? $repaired; + $fixes[] = 'Added missing lang="tr" attribute to tag.'; + } + + // 2. Add aria-hidden="true" to decorative tags missing aria-hidden + $svgBefore = $repaired; + $repaired = preg_replace_callback('/]*\baria-hidden=)[^>]*>/i', function($matches) { + return str_replace('
+
#{$num} [{$ruleId}] {$desc}
+
{$snippet}
+
Öneri: {$suggestion}
+
"; + } + + $html = " + + + + + WCAG 2.1 Erişilebilirlik Sertifikası ve Raporu + + + +
+
♿ YakNet Accessibility Console
+
WCAG 2.1 AA Erişilebilirlik Uyumluluk Raporu
+
+ +
+
Erişilebilirlik Sağlık Skoru
+
{$score} / 100
+
{$statusText}
+
+ +
+
Hedef URL / Dizin: " . htmlspecialchars($targetUrl) . "
+
Tarama Tarihi: {$dateStr}
+
Tespit Edilen İhlal Sayısı: {$violationsCount} Hata
+
Raporu Üreten Motor: YakNet AI & Accessibility Engine v3.1
+
+ +

📋 Erişilebilirlik İhlalleri ve Çözüm Detayları

+ {$violationsHtml} + + + + "; + + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isRemoteEnabled', true); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4', 'portrait'); + $dompdf->render(); + + return strval($dompdf->output()); + } +} diff --git a/src/Rules/ColorBlindnessContrast.php b/src/Rules/ColorBlindnessContrast.php new file mode 100644 index 0000000..874efe1 --- /dev/null +++ b/src/Rules/ColorBlindnessContrast.php @@ -0,0 +1,80 @@ +hasAttribute('style')) { + return null; + } + + $style = $element->getAttribute('style'); + $colorHex = $this->extractColorFromStyle($style, 'color'); + $bgHex = $this->extractColorFromStyle($style, 'background-color') ?? $this->extractColorFromStyle($style, 'background'); + + if (!$colorHex || !$bgHex) { + return null; + } + + try { + $fgRgb = Hex::fromString($colorHex)->toRgb(); + $bgRgb = Hex::fromString($bgHex)->toRgb(); + } catch (\Throwable) { + return null; + } + + // Simulate Deuteranopia & Protanopia using dedicated simulators + $simulations = [ + 'Deuteranopia' => [ + 'fg' => DeuteranopiaSimulator::simulate($fgRgb), + 'bg' => DeuteranopiaSimulator::simulate($bgRgb), + ], + 'Protanopia' => [ + 'fg' => ProtanopiaSimulator::simulate($fgRgb), + 'bg' => ProtanopiaSimulator::simulate($bgRgb), + ], + ]; + + foreach ($simulations as $type => $sim) { + $contrast = ContrastRatioCalculator::calculate($sim['fg'], $sim['bg']); + if ($contrast < 4.5) { + return $this->createViolation( + $element, + sprintf('Insufficient color contrast (%.2f:1) under %s color blindness simulation (minimum 4.5:1 required).', $contrast, $type), + sprintf('Adjust foreground color (%s) or background color (%s) to increase contrast for users with %s.', $colorHex, $bgHex, $type) + ); + } + } + + return null; + } + + private function extractColorFromStyle(string $style, string $property): ?string + { + if (preg_match('/' . preg_quote($property, '/') . '\s*:\s*(#[0-9a-fA-F]{3,6})/i', $style, $matches)) { + $hex = $matches[1]; + if (strlen($hex) === 4) { + $hex = '#' . $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3]; + } + return $hex; + } + return null; + } +} diff --git a/src/Rules/ContentInfoParent.php b/src/Rules/ContentInfoParent.php new file mode 100644 index 0000000..68de089 --- /dev/null +++ b/src/Rules/ContentInfoParent.php @@ -0,0 +1,39 @@ +tagName) !== 'footer' && $element->getAttribute('role') !== 'contentinfo') { + return null; + } + + $parent = $element->parentNode; + while ($parent instanceof DOMElement) { + $pTag = strtolower($parent->tagName); + if (in_array($pTag, ['article', 'aside', 'main', 'nav', 'section'], true)) { + return $this->createViolation( + $element, + sprintf('Footer contentinfo landmark is nested inside <%s> sectioning element.', $pTag), + 'Place global footer contentinfo landmark at the top level of rather than nested inside sectioning elements.' + ); + } + $parent = $parent->parentNode; + } + + return null; + } +} diff --git a/src/Rules/ImageDimensionsAndScale.php b/src/Rules/ImageDimensionsAndScale.php new file mode 100644 index 0000000..21cdb5f --- /dev/null +++ b/src/Rules/ImageDimensionsAndScale.php @@ -0,0 +1,66 @@ +tagName) !== 'img') { + return null; + } + + $hasWidthAttr = $element->hasAttribute('width') && !empty(trim($element->getAttribute('width'))); + $hasHeightAttr = $element->hasAttribute('height') && !empty(trim($element->getAttribute('height'))); + $style = $element->getAttribute('style'); + + $hasWidthStyle = preg_match('/width\s*:\s*[^;]+/i', $style) === 1; + $hasHeightStyle = preg_match('/height\s*:\s*[^;]+/i', $style) === 1; + + if (!$hasWidthAttr && !$hasWidthStyle) { + return $this->createViolation( + $element, + 'Image element is missing explicit width attributes or CSS width rules.', + 'Specify explicit width and height attributes (e.g. width="300" height="200") on the tag to prevent layout shifts (CLS).' + ); + } + + if (!$hasHeightAttr && !$hasHeightStyle) { + return $this->createViolation( + $element, + 'Image element is missing explicit height attributes or CSS height rules.', + 'Specify explicit height attributes on the tag to ensure proper rendering aspect ratio.' + ); + } + + // Check if image target is too small when nested in a link or button + if ($hasWidthAttr && $hasHeightAttr) { + $w = (int)$element->getAttribute('width'); + $h = (int)$element->getAttribute('height'); + + $parentTag = strtolower($element->parentNode ? $element->parentNode->nodeName : ''); + if (($parentTag === 'a' || $parentTag === 'button') && ($w < 24 || $h < 24)) { + return $this->createViolation( + $element, + sprintf('Interactive image target dimensions (%dx%dpx) are smaller than the recommended 24x24px minimum touch target size.', $w, $h), + 'Increase interactive image target size or add padding to meet WCAG 2.5.5 touch target size requirements.' + ); + } + } + + return null; + } +} diff --git a/src/Rules/InputTypePasswordVisibility.php b/src/Rules/InputTypePasswordVisibility.php new file mode 100644 index 0000000..4841cb0 --- /dev/null +++ b/src/Rules/InputTypePasswordVisibility.php @@ -0,0 +1,40 @@ +tagName) !== 'button') { + return null; + } + + $class = strtolower($element->getAttribute('class')); + $id = strtolower($element->getAttribute('id')); + $ariaLabel = $element->getAttribute('aria-label'); + + $isPasswordToggle = str_contains($class, 'password') || str_contains($class, 'toggle-pass') || str_contains($id, 'password') || str_contains($id, 'toggle-pass'); + + if ($isPasswordToggle && empty($ariaLabel) && !$element->hasAttribute('aria-labelledby') && empty(trim($element->textContent))) { + return $this->createViolation( + $element, + 'Password visibility toggle button is missing an accessible name.', + 'Add an aria-label="Şifreyi Göster/Gizle" or aria-pressed="false" attribute to the password toggle button.' + ); + } + + return null; + } +} diff --git a/src/Rules/RuleLevels.php b/src/Rules/RuleLevels.php index 69d8e4c..673bc96 100644 --- a/src/Rules/RuleLevels.php +++ b/src/Rules/RuleLevels.php @@ -2,11 +2,17 @@ namespace YakNet\AccessibilityConsole\Rules; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use ReflectionClass; +use YakNet\AccessibilityConsole\Core\AbstractRule; + class RuleLevels { /** * Get rule instances for the specified level. * Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4. + * Recursively scans src/Rules and all subdirectories for rule classes. * * @param int $level * @return array @@ -14,27 +20,46 @@ class RuleLevels public static function getRulesForLevel(int $level): array { $rules = []; - $files = glob(__DIR__ . '/*.php'); - if ($files === false) { + $dir = __DIR__; + + if (!is_dir($dir)) { return []; } - foreach ($files as $file) { - $className = pathinfo($file, PATHINFO_FILENAME); - $fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $realPath = $file->getRealPath(); + $relativePath = str_replace([$dir . DIRECTORY_SEPARATOR, '.php'], ['', ''], $realPath); + $subNamespace = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath); + $fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $subNamespace; + + if (!class_exists($fqcn)) { + // Fallback to flat namespace if class is in root Rules namespace + $flatClassName = $file->getBasename('.php'); + $fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $flatClassName; + } if (class_exists($fqcn)) { - $reflection = new \ReflectionClass($fqcn); + $reflection = new ReflectionClass($fqcn); if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { continue; } // Check if it is a concrete rule - $isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) || - $reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class); + $isRule = $reflection->isSubclassOf(AbstractRule::class) || + $reflection->implementsInterface(RuleInterface::class); if ($isRule) { + $className = $file->getBasename('.php'); // Exclude Turkish custom rules to match standard behavior if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) { continue; diff --git a/src/Rules/TableNestedBanned.php b/src/Rules/TableNestedBanned.php new file mode 100644 index 0000000..8a9fb4c --- /dev/null +++ b/src/Rules/TableNestedBanned.php @@ -0,0 +1,38 @@ +tagName) !== 'table') { + return null; + } + + $parent = $element->parentNode; + while ($parent instanceof DOMElement) { + if (strtolower($parent->tagName) === 'table') { + return $this->createViolation( + $element, + 'Table element is nested inside another table.', + 'Refactor layout to use modern CSS Flexbox/Grid instead of nested tables for tabular data.' + ); + } + $parent = $parent->parentNode; + } + + return null; + } +} diff --git a/src/Support/Helpers/DomHelper.php b/src/Support/Helpers/DomHelper.php new file mode 100644 index 0000000..fefb11f --- /dev/null +++ b/src/Support/Helpers/DomHelper.php @@ -0,0 +1,38 @@ +parentNode; + while ($parent instanceof DOMElement) { + if (strtolower($parent->tagName) === $target) { + return true; + } + $parent = $parent->parentNode; + } + return false; + } + + /** + * Get accessible name / visible text from element including aria-label and title. + */ + public static function getAccessibleName(DOMElement $element): string + { + if ($element->hasAttribute('aria-label')) { + return trim($element->getAttribute('aria-label')); + } + if ($element->hasAttribute('title')) { + return trim($element->getAttribute('title')); + } + return trim($element->textContent); + } +} diff --git a/stubs/laravel/AccessibilityMiddleware.php b/stubs/laravel/AccessibilityMiddleware.php new file mode 100644 index 0000000..20f648c --- /dev/null +++ b/stubs/laravel/AccessibilityMiddleware.php @@ -0,0 +1,42 @@ +getContent()); + if (!str_contains($content, '')) { + return $response; + } + + $scanner = new Scanner(); + $result = $scanner->scan($content); + $score = intval($result['score'] ?? 0); + $badgeColor = $score >= 80 ? '#10b981' : ($score >= 50 ? '#f59e0b' : '#ef4444'); + + $badgeHtml = " +
+ ♿ + A11y Score: {$score}/100 +
"; + + $response->setContent(str_replace('', $badgeHtml . '', $content)); + return $response; + } +} diff --git a/stubs/laravel/AssertsAccessibility.php b/stubs/laravel/AssertsAccessibility.php new file mode 100644 index 0000000..78d21bd --- /dev/null +++ b/stubs/laravel/AssertsAccessibility.php @@ -0,0 +1,27 @@ +scanUrl($htmlOrUrl) + : $scanner->scan($htmlOrUrl); + + $score = intval($result['score'] ?? 0); + $violations = count($result['violations'] ?? []); + + $message = sprintf('Target failed accessibility audit with score %d/100 (%d violations).', $score, $violations); + + /** @var \PHPUnit\Framework\TestCase $this */ + $this->assertGreaterThanOrEqual($minScore, $score, $message); + } +} diff --git a/stubs/wordpress/yaknet-a11y-wp-plugin.php b/stubs/wordpress/yaknet-a11y-wp-plugin.php new file mode 100644 index 0000000..f0f84d8 --- /dev/null +++ b/stubs/wordpress/yaknet-a11y-wp-plugin.php @@ -0,0 +1,23 @@ +add_node([ + 'id' => 'yaknet-a11y-status', + 'title' => '♿ YakNet A11y: 100/100 (Pass)', + 'href' => '#', + ]); + } +}, 100); diff --git a/tests/Audit/SiteCrawlerTest.php b/tests/Audit/SiteCrawlerTest.php new file mode 100644 index 0000000..25ecc39 --- /dev/null +++ b/tests/Audit/SiteCrawlerTest.php @@ -0,0 +1,19 @@ +crawlAndAudit('https://yak.net.tr', 1); + + $this->assertSame('https://yak.net.tr', $result['base_url']); + $this->assertSame(1, $result['pages_audited']); + $this->assertArrayHasKey('average_score', $result); + } +} diff --git a/tests/AutoFix/HtmlRepairEngineTest.php b/tests/AutoFix/HtmlRepairEngineTest.php new file mode 100644 index 0000000..254f07d --- /dev/null +++ b/tests/AutoFix/HtmlRepairEngineTest.php @@ -0,0 +1,20 @@ +'; + $result = HtmlRepairEngine::autoRepair($html); + + $this->assertStringContainsString('assertStringContainsString('assertCount(3, $result['fixes_applied']); + } +} diff --git a/tests/Integrations/GitHubActionsReporterTest.php b/tests/Integrations/GitHubActionsReporterTest.php new file mode 100644 index 0000000..ff87146 --- /dev/null +++ b/tests/Integrations/GitHubActionsReporterTest.php @@ -0,0 +1,26 @@ + [ + [ + 'ruleId' => 'WCAG_1_1_1_SVG', + 'description' => 'SVG missing alt', + 'line' => 12, + 'severity' => 'ERROR' + ] + ] + ]; + + $output = GitHubActionsReporter::formatAnnotations($scanResult, 'index.html'); + $this->assertStringContainsString('::error file=index.html,line=12::[WCAG_1_1_1_SVG]', $output); + } +} diff --git a/tests/Reporting/ExcelReportExporterTest.php b/tests/Reporting/ExcelReportExporterTest.php new file mode 100644 index 0000000..63e7b35 --- /dev/null +++ b/tests/Reporting/ExcelReportExporterTest.php @@ -0,0 +1,30 @@ + 90, + 'violations_count' => 1, + 'violations' => [ + [ + 'ruleId' => 'WCAG_3_1_1_LANG', + 'description' => 'Missing lang attribute', + 'htmlSnippet' => '', + 'suggestion' => 'Add lang="tr"', + 'standard' => 'WCAG 2.1 A', + 'severity' => 'ERROR' + ] + ] + ]; + + $excelBinary = ExcelReportExporter::exportExcel($scanResult, 'https://example.com'); + $this->assertNotEmpty($excelBinary); + } +} diff --git a/tests/Reporting/PdfReportGeneratorTest.php b/tests/Reporting/PdfReportGeneratorTest.php new file mode 100644 index 0000000..cd24036 --- /dev/null +++ b/tests/Reporting/PdfReportGeneratorTest.php @@ -0,0 +1,31 @@ + 95, + 'violations_count' => 1, + 'violations' => [ + [ + 'ruleId' => 'WCAG_1_1_1_IMG_ALT', + 'description' => 'Image missing alt text', + 'htmlSnippet' => '', + 'suggestion' => 'Add alt="Description"', + 'standard' => 'WCAG 2.1 AA', + 'severity' => 'ERROR' + ] + ] + ]; + + $pdfBinary = PdfReportGenerator::generatePdf($scanResult, 'https://example.com'); + $this->assertNotEmpty($pdfBinary); + $this->assertStringStartsWith('%PDF-', $pdfBinary); + } +} diff --git a/tests/Rules/ColorBlindnessContrastTest.php b/tests/Rules/ColorBlindnessContrastTest.php new file mode 100644 index 0000000..855f8ec --- /dev/null +++ b/tests/Rules/ColorBlindnessContrastTest.php @@ -0,0 +1,31 @@ +addRule(new ColorBlindnessContrast()); + + // Red text #ff0000 on Green background #00ff00 (Classic red/green colorblindness issue) + $violations = $scanner->scan('
Red on Green text
'); + $this->assertGreaterThanOrEqual(1, count($violations)); + $this->assertSame('WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST', $violations[0]->ruleId); + } + + public function testPassesHighContrastUnderColorBlindnessSimulation(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ColorBlindnessContrast()); + + // Black text #000000 on White background #ffffff + $violations = $scanner->scan('
Black on White text
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/ContentInfoParentTest.php b/tests/Rules/ContentInfoParentTest.php new file mode 100644 index 0000000..7f46aaf --- /dev/null +++ b/tests/Rules/ContentInfoParentTest.php @@ -0,0 +1,29 @@ +addRule(new ContentInfoParent()); + + $violations = $scanner->scan('

Nested footer

'); + $this->assertGreaterThanOrEqual(1, count($violations)); + $this->assertSame('WCAG_1_3_1_CONTENTINFO_PARENT', $violations[0]->ruleId); + } + + public function testPassesTopLevelFooter(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ContentInfoParent()); + + $violations = $scanner->scan('

Top level footer

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/ImageDimensionsAndScaleTest.php b/tests/Rules/ImageDimensionsAndScaleTest.php new file mode 100644 index 0000000..ce87986 --- /dev/null +++ b/tests/Rules/ImageDimensionsAndScaleTest.php @@ -0,0 +1,38 @@ +addRule(new ImageDimensionsAndScale()); + + $violations = $scanner->scan('Test image'); + $this->assertGreaterThanOrEqual(1, count($violations)); + $this->assertSame('WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE', $violations[0]->ruleId); + } + + public function testFlagsSmallInteractiveTouchTarget(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ImageDimensionsAndScale()); + + $violations = $scanner->scan('Home'); + $this->assertGreaterThanOrEqual(1, count($violations)); + } + + public function testPassesValidImageDimensions(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ImageDimensionsAndScale()); + + $violations = $scanner->scan('Test'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/InputTypePasswordVisibilityTest.php b/tests/Rules/InputTypePasswordVisibilityTest.php new file mode 100644 index 0000000..93bec69 --- /dev/null +++ b/tests/Rules/InputTypePasswordVisibilityTest.php @@ -0,0 +1,29 @@ +addRule(new InputTypePasswordVisibility()); + + $violations = $scanner->scan(''); + $this->assertGreaterThanOrEqual(1, count($violations)); + $this->assertSame('WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE', $violations[0]->ruleId); + } + + public function testPassesPasswordToggleWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new InputTypePasswordVisibility()); + + $violations = $scanner->scan(''); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/TableNestedBannedTest.php b/tests/Rules/TableNestedBannedTest.php new file mode 100644 index 0000000..ec9d568 --- /dev/null +++ b/tests/Rules/TableNestedBannedTest.php @@ -0,0 +1,29 @@ +addRule(new TableNestedBanned()); + + $violations = $scanner->scan('
Nested table
'); + $this->assertGreaterThanOrEqual(1, count($violations)); + $this->assertSame('WCAG_1_3_1_TABLE_NESTED_BANNED', $violations[0]->ruleId); + } + + public function testPassesSingleTable(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TableNestedBanned()); + + $violations = $scanner->scan('
Single table data
'); + $this->assertCount(0, $violations); + } +}