From 2de1f3487886ce5a0d167eb5da11339c1fbbaf46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Yak=C4=B1=C5=9Ft=C4=B1r?= Date: Sun, 16 Aug 2026 22:00:30 +0300 Subject: [PATCH] feat(v3.2.7): expand WCAG ruleset to 127 rules, add HTML syntax linter, single-pass rule engine, template preprocessor, and accessibility analytics --- README.md | 154 +++++--- bin/a11y | 2 +- composer.json | 2 +- src/Analytics/AccessibilityTelemetry.php | 49 +++ src/Analytics/ComplianceMatrixGenerator.php | 77 ++++ src/Analytics/ReportSummaryModel.php | 42 +++ src/Core/AbstractRule.php | 17 +- src/Core/Analyser.php | 109 ++---- src/Core/Locator/PreciseElementLocator.php | 107 ++++++ src/Core/RuleEngine.php | 63 +++- src/Core/RuleRegistry.php | 89 +++++ src/Core/Scanner.php | 9 + src/Core/Violation.php | 6 +- src/Parser/BladeParser.php | 31 +- src/Parser/TemplatePreprocessor.php | 74 ++++ src/Parser/TwigParser.php | 25 +- src/Reference/WcagDatabase.php | 169 +++++++++ src/Reporting/HtmlDashboardReporter.php | 12 +- src/Rules/AccesskeyDuplicate.php | 76 ++++ src/Rules/AriaBrailleEquivalent.php | 74 ++++ src/Rules/AriaCurrentValid.php | 61 ++++ src/Rules/AriaDisabledFocusable.php | 70 ++++ src/Rules/AriaErrorMessage.php | 78 ++++ src/Rules/AriaLabelMisuseOnGeneric.php | 62 ++++ src/Rules/AriaProhibitedAttr.php | 77 ++++ src/Rules/AriaRedundantRole.php | 67 ++++ src/Rules/AriaRoledescription.php | 72 ++++ src/Rules/ButtonNestedInteractive.php | 64 ++++ src/Rules/ColorContrast.php | 10 +- src/Rules/DetailsMissingSummary.php | 77 ++++ src/Rules/DraggingMovements.php | 70 ++++ src/Rules/DuplicateId.php | 30 +- src/Rules/EmptyHeading.php | 107 +++++- src/Rules/FocusNotObscured.php | 69 ++++ src/Rules/FormLabel.php | 74 +++- src/Rules/HeadingOrder.php | 81 +++-- src/Rules/HtmlDirValid.php | 58 +++ src/Rules/HtmlSyntaxValid.php | 78 ++++ src/Rules/IframeTitle.php | 52 ++- src/Rules/InputAutocompleteAppropriate.php | 83 +++++ src/Rules/InputMissingAutocomplete.php | 78 ++-- src/Rules/MediaControls.php | 64 ++++ src/Rules/MetaCharsetPresent.php | 79 ++++ src/Rules/MeterAccessibility.php | 86 +++++ src/Rules/NestedInteractive.php | 40 ++- src/Rules/PageTitle.php | 47 +-- src/Rules/RoleImgAlt.php | 67 ++++ src/Rules/SelectValidChildren.php | 68 ++++ src/Rules/StrictRuleSet.php | 8 +- src/Rules/TableHeaderNotEmpty.php | 90 +++++ src/Rules/TableHeadersAttributeValid.php | 95 +++++ src/Rules/TargetSizeMinimum.php | 93 +++++ src/Rules/TrackValidation.php | 84 +++++ src/Syntax/HtmlSyntaxLinter.php | 337 ++++++++++++++++++ src/Syntax/SyntaxIssue.php | 32 ++ .../Analytics/AccessibilityTelemetryTest.php | 35 ++ .../ComplianceMatrixGeneratorTest.php | 48 +++ tests/Core/PreciseElementLocatorTest.php | 35 ++ tests/Core/RuleRegistryTest.php | 33 ++ tests/Parser/TemplatePreprocessorTest.php | 55 +++ tests/Rules/AccesskeyDuplicateTest.php | 29 ++ tests/Rules/AriaBrailleEquivalentTest.php | 29 ++ tests/Rules/AriaCurrentValidTest.php | 38 ++ tests/Rules/AriaDisabledFocusableTest.php | 29 ++ tests/Rules/AriaErrorMessageTest.php | 38 ++ tests/Rules/AriaLabelMisuseOnGenericTest.php | 33 ++ tests/Rules/AriaProhibitedAttrTest.php | 38 ++ tests/Rules/AriaRedundantRoleTest.php | 29 ++ tests/Rules/AriaRoledescriptionTest.php | 38 ++ tests/Rules/ButtonNestedInteractiveTest.php | 38 ++ tests/Rules/DetailsMissingSummaryTest.php | 38 ++ tests/Rules/DraggingMovementsTest.php | 33 ++ tests/Rules/DuplicateIdTest.php | 29 ++ tests/Rules/EmptyHeadingTest.php | 56 +++ tests/Rules/FocusNotObscuredTest.php | 33 ++ tests/Rules/FormLabelTest.php | 47 +++ tests/Rules/HeadingOrderTest.php | 42 +++ tests/Rules/HtmlDirValidTest.php | 29 ++ tests/Rules/HtmlSyntaxValidTest.php | 29 ++ tests/Rules/IframeTitleTest.php | 47 +++ .../InputAutocompleteAppropriateTest.php | 33 ++ tests/Rules/InputMissingAutocompleteTest.php | 20 +- tests/Rules/MediaControlsTest.php | 38 ++ tests/Rules/MetaCharsetPresentTest.php | 29 ++ tests/Rules/MeterAccessibilityTest.php | 29 ++ tests/Rules/NestedInteractiveTest.php | 38 ++ tests/Rules/RoleImgAltTest.php | 38 ++ tests/Rules/SelectValidChildrenTest.php | 29 ++ tests/Rules/TableHeaderNotEmptyTest.php | 38 ++ .../Rules/TableHeadersAttributeValidTest.php | 29 ++ tests/Rules/TargetSizeMinimumTest.php | 38 ++ tests/Rules/TrackValidationTest.php | 38 ++ tests/Syntax/HtmlSyntaxLinterTest.php | 64 ++++ 93 files changed, 4879 insertions(+), 305 deletions(-) create mode 100644 src/Analytics/AccessibilityTelemetry.php create mode 100644 src/Analytics/ComplianceMatrixGenerator.php create mode 100644 src/Analytics/ReportSummaryModel.php create mode 100644 src/Core/Locator/PreciseElementLocator.php create mode 100644 src/Core/RuleRegistry.php create mode 100644 src/Parser/TemplatePreprocessor.php create mode 100644 src/Rules/AccesskeyDuplicate.php create mode 100644 src/Rules/AriaBrailleEquivalent.php create mode 100644 src/Rules/AriaCurrentValid.php create mode 100644 src/Rules/AriaDisabledFocusable.php create mode 100644 src/Rules/AriaErrorMessage.php create mode 100644 src/Rules/AriaLabelMisuseOnGeneric.php create mode 100644 src/Rules/AriaProhibitedAttr.php create mode 100644 src/Rules/AriaRedundantRole.php create mode 100644 src/Rules/AriaRoledescription.php create mode 100644 src/Rules/ButtonNestedInteractive.php create mode 100644 src/Rules/DetailsMissingSummary.php create mode 100644 src/Rules/DraggingMovements.php create mode 100644 src/Rules/FocusNotObscured.php create mode 100644 src/Rules/HtmlDirValid.php create mode 100644 src/Rules/HtmlSyntaxValid.php create mode 100644 src/Rules/InputAutocompleteAppropriate.php create mode 100644 src/Rules/MediaControls.php create mode 100644 src/Rules/MetaCharsetPresent.php create mode 100644 src/Rules/MeterAccessibility.php create mode 100644 src/Rules/RoleImgAlt.php create mode 100644 src/Rules/SelectValidChildren.php create mode 100644 src/Rules/TableHeaderNotEmpty.php create mode 100644 src/Rules/TableHeadersAttributeValid.php create mode 100644 src/Rules/TargetSizeMinimum.php create mode 100644 src/Rules/TrackValidation.php create mode 100644 src/Syntax/HtmlSyntaxLinter.php create mode 100644 src/Syntax/SyntaxIssue.php create mode 100644 tests/Analytics/AccessibilityTelemetryTest.php create mode 100644 tests/Analytics/ComplianceMatrixGeneratorTest.php create mode 100644 tests/Core/PreciseElementLocatorTest.php create mode 100644 tests/Core/RuleRegistryTest.php create mode 100644 tests/Parser/TemplatePreprocessorTest.php create mode 100644 tests/Rules/AccesskeyDuplicateTest.php create mode 100644 tests/Rules/AriaBrailleEquivalentTest.php create mode 100644 tests/Rules/AriaCurrentValidTest.php create mode 100644 tests/Rules/AriaDisabledFocusableTest.php create mode 100644 tests/Rules/AriaErrorMessageTest.php create mode 100644 tests/Rules/AriaLabelMisuseOnGenericTest.php create mode 100644 tests/Rules/AriaProhibitedAttrTest.php create mode 100644 tests/Rules/AriaRedundantRoleTest.php create mode 100644 tests/Rules/AriaRoledescriptionTest.php create mode 100644 tests/Rules/ButtonNestedInteractiveTest.php create mode 100644 tests/Rules/DetailsMissingSummaryTest.php create mode 100644 tests/Rules/DraggingMovementsTest.php create mode 100644 tests/Rules/DuplicateIdTest.php create mode 100644 tests/Rules/EmptyHeadingTest.php create mode 100644 tests/Rules/FocusNotObscuredTest.php create mode 100644 tests/Rules/FormLabelTest.php create mode 100644 tests/Rules/HeadingOrderTest.php create mode 100644 tests/Rules/HtmlDirValidTest.php create mode 100644 tests/Rules/HtmlSyntaxValidTest.php create mode 100644 tests/Rules/IframeTitleTest.php create mode 100644 tests/Rules/InputAutocompleteAppropriateTest.php create mode 100644 tests/Rules/MediaControlsTest.php create mode 100644 tests/Rules/MetaCharsetPresentTest.php create mode 100644 tests/Rules/MeterAccessibilityTest.php create mode 100644 tests/Rules/NestedInteractiveTest.php create mode 100644 tests/Rules/RoleImgAltTest.php create mode 100644 tests/Rules/SelectValidChildrenTest.php create mode 100644 tests/Rules/TableHeaderNotEmptyTest.php create mode 100644 tests/Rules/TableHeadersAttributeValidTest.php create mode 100644 tests/Rules/TargetSizeMinimumTest.php create mode 100644 tests/Rules/TrackValidationTest.php create mode 100644 tests/Syntax/HtmlSyntaxLinterTest.php diff --git a/README.md b/README.md index 815282b..7bbebb0 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,49 @@ -# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis) +# YakNet Accessibility Console 3.0 (AI-Powered Static Analysis & WCAG 2.2 Linter) [![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net) +[![PHPStan](https://img.shields.io/badge/PHPStan-Level%209-brightgreen.svg)](https://phpstan.org) +[![Tests](https://img.shields.io/badge/PHPUnit-191%20passed-success.svg)](https://phpunit.de) +[![Rules](https://img.shields.io/badge/WCAG%20Rules-127%20Active-purple.svg)](https://www.w3.org/WAI/standards-guidelines/wcag/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com) +[![AI Powered](https://img.shields.io/badge/AI%20Self--Healing-Google%20Gemini-orange.svg)](https://ai.google.dev) -**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir. +**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **127 benzersiz kural setiyle** denetleyen ve **Yapay Zeka (Google Gemini AI)** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir. -PHPStan ve PHP-CS-Fixer esintili yapısıyla, yerel görünüm şablonlarınızı (`.blade.php`, `.twig`, `.html`, `.php`) doğrudan tarayabilir, baseline desteğiyle mevcut hataları yok sayabilir ve CI/CD süreçlerinize tam entegre çalışabilir. +PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`), Twig (`.twig`), PHP (`.php`) ve standart HTML şablonlarınızı doğrudan tarar, baseline desteğiyle mevcut teknik borcu yönetir ve CI/CD pipeline süreçlerinize sıfır yapılandırmayla entegre olur. --- ## 🌟 Öne Çıkan Özellikler -- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve kaynak kodunuzu (Blade, Twig, PHP, HTML) otomatik olarak tamir eder. -- **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler. -- **🛡️ Baseline Desteği:** `a11y-baseline.json` oluşturarak mevcut erişilebilirlik hatalarını yok sayabilir, CI/CD pipeline'ınızın sadece yeni hatalarda kırılmasını sağlayabilirsiniz. -- **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz. -- **🔌 Çoklu Çıktı Formatları:** - - `console`: Renkli ve düzenli terminal tablosu. - - `json`: Makine tarafından okunabilir JSON çıktısı. - - `github`: GitHub Actions PR süreçlerinde doğrudan inline hata göstermek için workflow formatı. -- **📊 Görsel Raporlama:** Sonuçları modern bir HTML Dashboard raporu olarak dışarı aktarın. +- **🛡️ 127 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli (Level 1-5) kural havuzu. +- **🔍 HTML & Şablon Söz Dizimi (Syntax/AST) Linter'ı:** + - Aynı etiket üzerinde yinelenen nitelikler (`Duplicate Attributes` - HTML5 3.2.4 ihlali) + - Kapanmamış veya çapraz kapanmış yapısal etiketler (`Mismatched / Unclosed Tags`) + - Void olmayan etiketlerde geçersiz self-closing kullanımı (`
`, ``, `

`) + - Boşluksuz bitişik nitelik söz dizimi (``) + - Kapanmamış HTML yorum blokları (`', $pos + 4); + if ($endPos === false) { + $line = $this->getLineFromOffset($html, $pos); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_UNCLOSED_COMMENT', + message: 'HTML comment is opened with .', + line: $line, + snippet: mb_substr($html, $pos, 80), + fixSuggestion: 'Close the comment with -->.', + severity: 'error' + ); + break; + } + $offset = $endPos + 3; + } + } + + /** + * @param SyntaxIssue[] $issues + */ + private function checkAttributes(string $html, array &$issues): void + { + // Match start tags + $pattern = '/<([a-zA-Z0-9:-]+)(\s+[^>]*)?>/s'; + if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $index => $match) { + $fullTag = $match[0]; + $offset = $match[1]; + $tagName = strtolower($matches[1][$index][0]); + + if (str_starts_with($fullTag, 'getLineFromOffset($html, $offset); + + // A. Check for missing space between attributes (e.g. href="x"class="y") + if (preg_match('/["\']([a-zA-Z0-9_:-]+)=["\']/', $attrString, $missingSpaceMatch, PREG_OFFSET_CAPTURE)) { + $mOffset = $offset + (int)$missingSpaceMatch[0][1]; + $mLine = $this->getLineFromOffset($html, $mOffset); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_MISSING_ATTR_SPACE', + message: "Missing whitespace separator between attributes on <{$tagName}>.", + line: $mLine, + snippet: $fullTag, + fixSuggestion: 'Insert a space between adjacent attributes.', + severity: 'warning' + ); + } + + // B. Parse individual attributes to check for duplicates + $attrNames = []; + $attrPattern = '/([a-zA-Z0-9_:-]+)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?/s'; + if (preg_match_all($attrPattern, $attrString, $attrMatches)) { + foreach ($attrMatches[1] as $aName) { + $normalizedName = strtolower(trim($aName)); + if ($normalizedName === '' || $normalizedName === '/') { + continue; + } + + if (isset($attrNames[$normalizedName])) { + $issues[] = new SyntaxIssue( + code: 'SYNTAX_DUPLICATE_ATTR', + message: "Duplicate attribute '{$normalizedName}' declared multiple times on <{$tagName}> element (HTML5 3.2.4 violation).", + line: $line, + snippet: $fullTag, + fixSuggestion: "Remove the duplicate '{$normalizedName}' attribute.", + severity: 'error' + ); + } else { + $attrNames[$normalizedName] = true; + } + } + } + } + } + } + + /** + * @param SyntaxIssue[] $issues + */ + private function checkInvalidSelfClosing(string $html, array &$issues): void + { + // Match where tagname is NOT in void elements list and not SVG/MathML + $pattern = '/<([a-zA-Z0-9-]+)(\s+[^>]*)?\/>/s'; + if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) { + foreach ($matches[0] as $index => $match) { + $fullTag = $match[0]; + $offset = $match[1]; + $tagName = strtolower($matches[1][$index][0]); + + if (in_array($tagName, self::$voidElements, true)) { + continue; // Allowed to self-close in HTML5/XHTML + } + + // Skip SVG and MathML subtrees/elements + if (in_array($tagName, ['path', 'circle', 'rect', 'line', 'polygon', 'polyline', 'ellipse', 'use', 'stop'], true)) { + continue; + } + + $line = $this->getLineFromOffset($html, $offset); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_INVALID_SELF_CLOSING', + message: "Self-closing syntax (<{$tagName} />) is invalid on non-void HTML element <{$tagName}>. It causes DOM tree distortion.", + line: $line, + snippet: $fullTag, + fixSuggestion: "Replace with explicit opening and closing tags: <{$tagName}>.", + severity: 'error' + ); + } + } + } + + /** + * @param SyntaxIssue[] $issues + */ + private function checkTagStructure(string $html, array &$issues): void + { + // Strip comments and script/style contents for structure check + $clean = preg_replace('//s', '', $html) ?? $html; + $clean = preg_replace('/]*>.*?<\/script>/si', '', $clean) ?? $clean; + $clean = preg_replace('/]*>.*?<\/style>/si', '', $clean) ?? $clean; + $clean = preg_replace('/<\?php.*?\?>/si', '', $clean) ?? $clean; + + // Find all tags in order: or + $tagPattern = '/<(\/)?([a-zA-Z0-9:-]+)(?:\s+[^>]*)?>/s'; + if (preg_match_all($tagPattern, $clean, $matches, PREG_OFFSET_CAPTURE)) { + $stack = []; + $trackedTags = [ + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', + 'details', 'summary', 'dialog', 'main', 'nav', 'header', 'footer', + 'section', 'article', 'aside', 'form', 'fieldset', 'div', 'span', 'p', 'a', 'button' + ]; + + foreach ($matches[0] as $index => $match) { + $fullMatch = $match[0]; + $offset = $match[1]; + $isClosing = ($matches[1][$index][0] === '/'); + $tagName = strtolower($matches[2][$index][0]); + + if (in_array($tagName, self::$voidElements, true)) { + continue; + } + + if (str_starts_with($fullMatch, '')) { + continue; + } + + if (!in_array($tagName, $trackedTags, true)) { + continue; + } + + if (!$isClosing) { + $stack[] = ['tag' => $tagName, 'offset' => $offset, 'line' => $this->getLineFromOffset($clean, $offset)]; + } else { + if (empty($stack)) { + $line = $this->getLineFromOffset($clean, $offset); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_UNOPENED_TAG', + message: "Closing tag has no matching opening tag.", + line: $line, + snippet: $fullMatch, + fixSuggestion: "Remove the stray closing tag.", + severity: 'error' + ); + } else { + $last = end($stack); + if ($last['tag'] === $tagName) { + array_pop($stack); + } else { + // Find if $tagName exists deeper in the stack + $foundIndex = null; + for ($i = count($stack) - 1; $i >= 0; $i--) { + if ($stack[$i]['tag'] === $tagName) { + $foundIndex = $i; + break; + } + } + + if ($foundIndex !== null) { + $unclosed = array_slice($stack, $foundIndex + 1); + $stack = array_slice($stack, 0, $foundIndex); + $line = $this->getLineFromOffset($clean, $offset); + foreach ($unclosed as $u) { + $issues[] = new SyntaxIssue( + code: 'SYNTAX_MISMATCHED_TAG', + message: "Mismatched closing tag ; <{$u['tag']}> opened at line {$u['line']} was not closed properly.", + line: $line, + snippet: $fullMatch, + fixSuggestion: "Close <{$u['tag']}> before .", + severity: 'error' + ); + } + } else { + $line = $this->getLineFromOffset($clean, $offset); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_UNOPENED_TAG', + message: "Closing tag has no matching opening tag.", + line: $line, + snippet: $fullMatch, + fixSuggestion: "Remove the stray closing tag.", + severity: 'error' + ); + } + } + } + } + } + + // Check any unclosed structural tags left on stack + foreach ($stack as $unclosed) { + // Ignore optional closing tags like p or li if unclosed at document end + if (in_array($unclosed['tag'], ['p', 'li', 'dt', 'dd', 'span'], true)) { + continue; + } + $issues[] = new SyntaxIssue( + code: 'SYNTAX_UNCLOSED_TAG', + message: "Unclosed tag <{$unclosed['tag']}> opened at line {$unclosed['line']} was never closed.", + line: $unclosed['line'], + snippet: "<{$unclosed['tag']}>", + fixSuggestion: "Add matching closing tag .", + severity: 'error' + ); + } + } + } + + /** + * @param SyntaxIssue[] $issues + */ + private function checkStructureTagMultiplicity(string $html, array &$issues): void + { + // 1. Multiple tags + if (preg_match_all('/]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) { + if (count($matches[0]) > 1) { + foreach (array_slice($matches[0], 1) as $match) { + $line = $this->getLineFromOffset($html, $match[1]); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_MULTIPLE_BODY', + message: 'Document contains multiple elements. Only one is permitted per HTML document.', + line: $line, + snippet: $match[0], + fixSuggestion: 'Merge contents into a single element.', + severity: 'error' + ); + } + } + } + + // 2. Multiple tags + if (preg_match_all('/]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) { + if (count($matches[0]) > 1) { + foreach (array_slice($matches[0], 1) as $match) { + $line = $this->getLineFromOffset($html, $match[1]); + $issues[] = new SyntaxIssue( + code: 'SYNTAX_MULTIPLE_HEAD', + message: 'Document contains multiple elements. Only one is permitted per HTML document.', + line: $line, + snippet: $match[0], + fixSuggestion: 'Merge head tags into a single element.', + severity: 'error' + ); + } + } + } + } + + private function getLineFromOffset(string $content, int $offset): int + { + if ($offset <= 0) { + return 1; + } + $sub = substr($content, 0, $offset); + return substr_count($sub, "\n") + 1; + } +} diff --git a/src/Syntax/SyntaxIssue.php b/src/Syntax/SyntaxIssue.php new file mode 100644 index 0000000..fdae9a8 --- /dev/null +++ b/src/Syntax/SyntaxIssue.php @@ -0,0 +1,32 @@ + + */ + public function toArray(): array + { + return [ + 'code' => $this->code, + 'message' => $this->message, + 'line' => $this->line, + 'column' => $this->column, + 'snippet' => $this->snippet, + 'fixSuggestion' => $this->fixSuggestion, + 'severity' => $this->severity, + ]; + } +} diff --git a/tests/Analytics/AccessibilityTelemetryTest.php b/tests/Analytics/AccessibilityTelemetryTest.php new file mode 100644 index 0000000..1a5bab5 --- /dev/null +++ b/tests/Analytics/AccessibilityTelemetryTest.php @@ -0,0 +1,35 @@ +telemetry = new AccessibilityTelemetry(); + } + + public function testComputesDefectDensityAndRatios(): void + { + $violations = [ + new Violation(ruleId: 'R1', message: 'M1', severity: Severity::ERROR, standard: WCAGStandard::A), + new Violation(ruleId: 'R2', message: 'M2', severity: Severity::WARNING, standard: WCAGStandard::AA), + ]; + + $metrics = $this->telemetry->computeMetrics($violations, 2000); + + $this->assertSame(2, $metrics['total_violations']); + $this->assertSame(1, $metrics['error_count']); + $this->assertSame(1, $metrics['warning_count']); + $this->assertSame(1.0, $metrics['defect_density_per_kloc']); // 2 violations / 2 kloc = 1.0 + $this->assertSame(50.0, $metrics['critical_blockers_ratio']); // 1 / 2 = 50% + } +} diff --git a/tests/Analytics/ComplianceMatrixGeneratorTest.php b/tests/Analytics/ComplianceMatrixGeneratorTest.php new file mode 100644 index 0000000..012a489 --- /dev/null +++ b/tests/Analytics/ComplianceMatrixGeneratorTest.php @@ -0,0 +1,48 @@ +generator = new ComplianceMatrixGenerator(); + } + + public function testGeneratesMatrixSuccessfully(): void + { + $violations = [ + new Violation( + ruleId: 'WCAG_1_1_1_ALT', + message: 'Image alt missing', + severity: Severity::ERROR, + standard: WCAGStandard::A + ), + new Violation( + ruleId: 'WCAG_2_4_4_LINK_TEXT', + message: 'Link text empty', + severity: Severity::ERROR, + standard: WCAGStandard::A + ), + ]; + + $matrix = $this->generator->generate($violations, 127); + + $this->assertArrayHasKey('overall_compliance_percentage', $matrix); + $this->assertArrayHasKey('principles', $matrix); + $this->assertArrayHasKey('levels', $matrix); + + $this->assertSame(1, $matrix['principles']['Perceivable']['violations']); + $this->assertSame(1, $matrix['principles']['Operable']['violations']); + $this->assertSame(0, $matrix['principles']['Understandable']['violations']); + $this->assertSame('Pass', $matrix['principles']['Understandable']['status']); + } +} diff --git a/tests/Core/PreciseElementLocatorTest.php b/tests/Core/PreciseElementLocatorTest.php new file mode 100644 index 0000000..4f2a50b --- /dev/null +++ b/tests/Core/PreciseElementLocatorTest.php @@ -0,0 +1,35 @@ +locator = new PreciseElementLocator(); + } + + public function testLocatesExactMatch(): void + { + $content = "\n\n

\n\n"; + $snippet = ''; + + $loc = $this->locator->locate($content, $snippet); + $this->assertSame(4, $loc['line']); + } + + public function testLocatesAttributeTokenMatch(): void + { + $content = "
\n\n
"; + // Snippet with different whitespace/attr arrangement + $snippet = '\n\n
"; + $processed = $this->preprocessor->preprocess($php, 'views/button.php'); + + $this->assertStringNotContainsString('assertStringNotContainsString('assertStringContainsString(''); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_BRAILLE', $violations[0]->ruleId); + } + + public function testPassesBrailleLabelWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaBrailleEquivalent()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaCurrentValidTest.php b/tests/Rules/AriaCurrentValidTest.php new file mode 100644 index 0000000..2c2bbfe --- /dev/null +++ b/tests/Rules/AriaCurrentValidTest.php @@ -0,0 +1,38 @@ +addRule(new AriaCurrentValid()); + + $violations = $scanner->scan('
Home
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_CURRENT', $violations[0]->ruleId); + } + + public function testPassesValidAriaCurrent(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaCurrentValid()); + + $violations = $scanner->scan('
Home
'); + $this->assertCount(0, $violations); + } + + public function testPassesBooleanAriaCurrent(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaCurrentValid()); + + $violations = $scanner->scan('
Step 2
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaDisabledFocusableTest.php b/tests/Rules/AriaDisabledFocusableTest.php new file mode 100644 index 0000000..67dd042 --- /dev/null +++ b/tests/Rules/AriaDisabledFocusableTest.php @@ -0,0 +1,29 @@ +addRule(new AriaDisabledFocusable()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_DISABLED', $violations[0]->ruleId); + } + + public function testPassesStandardDisabled(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaDisabledFocusable()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaErrorMessageTest.php b/tests/Rules/AriaErrorMessageTest.php new file mode 100644 index 0000000..3317f1b --- /dev/null +++ b/tests/Rules/AriaErrorMessageTest.php @@ -0,0 +1,38 @@ +addRule(new AriaErrorMessage()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_3_3_1_ARIA_ERRORMESSAGE', $violations[0]->ruleId); + } + + public function testFlagsMissingAriaInvalid(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaErrorMessage()); + + $violations = $scanner->scan('
Error
'); + $this->assertCount(1, $violations); + } + + public function testPassesValidErrorMessageSetup(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaErrorMessage()); + + $violations = $scanner->scan('
Invalid Email Address
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaLabelMisuseOnGenericTest.php b/tests/Rules/AriaLabelMisuseOnGenericTest.php new file mode 100644 index 0000000..38edbb0 --- /dev/null +++ b/tests/Rules/AriaLabelMisuseOnGenericTest.php @@ -0,0 +1,33 @@ +addRule(new AriaLabelMisuseOnGeneric()); + + $html = '
Content
'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_LABEL_GENERIC', $violations[0]->ruleId); + } + + public function testPassesDivWithRoleAndAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaLabelMisuseOnGeneric()); + + $html = '
Content
'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaProhibitedAttrTest.php b/tests/Rules/AriaProhibitedAttrTest.php new file mode 100644 index 0000000..5befa1e --- /dev/null +++ b/tests/Rules/AriaProhibitedAttrTest.php @@ -0,0 +1,38 @@ +addRule(new AriaProhibitedAttr()); + + $violations = $scanner->scan('
Content
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_PROHIBITED_ATTR', $violations[0]->ruleId); + } + + public function testFlagsAriaLabelledbyOnNoneRole(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaProhibitedAttr()); + + $violations = $scanner->scan('
Content
'); + $this->assertCount(1, $violations); + } + + public function testPassesValidRolesWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaProhibitedAttr()); + + $violations = $scanner->scan('
Content
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaRedundantRoleTest.php b/tests/Rules/AriaRedundantRoleTest.php new file mode 100644 index 0000000..8b3078e --- /dev/null +++ b/tests/Rules/AriaRedundantRoleTest.php @@ -0,0 +1,29 @@ +addRule(new AriaRedundantRole()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_REDUNDANT_ROLE', $violations[0]->ruleId); + } + + public function testPassesNavWithoutRedundantRole(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaRedundantRole()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/AriaRoledescriptionTest.php b/tests/Rules/AriaRoledescriptionTest.php new file mode 100644 index 0000000..0d89e79 --- /dev/null +++ b/tests/Rules/AriaRoledescriptionTest.php @@ -0,0 +1,38 @@ +addRule(new AriaRoledescription()); + + $violations = $scanner->scan('
Content
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_ARIA_ROLEDESCRIPTION', $violations[0]->ruleId); + } + + public function testFlagsRoledescriptionOnPresentation(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaRoledescription()); + + $violations = $scanner->scan('
Content
'); + $this->assertCount(1, $violations); + } + + public function testPassesValidRoledescription(): void + { + $scanner = new Scanner(); + $scanner->addRule(new AriaRoledescription()); + + $violations = $scanner->scan('
Slide Content
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/ButtonNestedInteractiveTest.php b/tests/Rules/ButtonNestedInteractiveTest.php new file mode 100644 index 0000000..c164798 --- /dev/null +++ b/tests/Rules/ButtonNestedInteractiveTest.php @@ -0,0 +1,38 @@ +addRule(new ButtonNestedInteractive()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_BUTTON_NESTED_INTERACTIVE', $violations[0]->ruleId); + } + + public function testFlagsNestedButtonInsideButton(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ButtonNestedInteractive()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + } + + public function testPassesStandardButton(): void + { + $scanner = new Scanner(); + $scanner->addRule(new ButtonNestedInteractive()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/DetailsMissingSummaryTest.php b/tests/Rules/DetailsMissingSummaryTest.php new file mode 100644 index 0000000..ae5517e --- /dev/null +++ b/tests/Rules/DetailsMissingSummaryTest.php @@ -0,0 +1,38 @@ +addRule(new DetailsMissingSummary()); + + $violations = $scanner->scan('

Some hidden details here

'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_DETAILS_SUMMARY', $violations[0]->ruleId); + } + + public function testFlagsDetailsWithEmptySummary(): void + { + $scanner = new Scanner(); + $scanner->addRule(new DetailsMissingSummary()); + + $violations = $scanner->scan('

Details

'); + $this->assertCount(1, $violations); + } + + public function testPassesDetailsWithValidSummary(): void + { + $scanner = new Scanner(); + $scanner->addRule(new DetailsMissingSummary()); + + $violations = $scanner->scan('
More info

Details content

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/DraggingMovementsTest.php b/tests/Rules/DraggingMovementsTest.php new file mode 100644 index 0000000..49e49a6 --- /dev/null +++ b/tests/Rules/DraggingMovementsTest.php @@ -0,0 +1,33 @@ +addRule(new DraggingMovements()); + + $html = '
Task 1
'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_2_5_7_DRAGGING_MOVEMENTS', $violations[0]->ruleId); + } + + public function testPassesDraggableWithKeyboardShortcutsOrButtons(): void + { + $scanner = new Scanner(); + $scanner->addRule(new DraggingMovements()); + + $html = '
Task 1
'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/DuplicateIdTest.php b/tests/Rules/DuplicateIdTest.php new file mode 100644 index 0000000..3401f4f --- /dev/null +++ b/tests/Rules/DuplicateIdTest.php @@ -0,0 +1,29 @@ +addRule(new DuplicateId()); + + $violations = $scanner->scan('
A
B
'); + $this->assertCount(2, $violations); + $this->assertSame('WCAG_4_1_2_DUPLICATE_ID', $violations[0]->ruleId); + } + + public function testPassesUniqueIds(): void + { + $scanner = new Scanner(); + $scanner->addRule(new DuplicateId()); + + $violations = $scanner->scan('
A
B
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/EmptyHeadingTest.php b/tests/Rules/EmptyHeadingTest.php new file mode 100644 index 0000000..d61f993 --- /dev/null +++ b/tests/Rules/EmptyHeadingTest.php @@ -0,0 +1,56 @@ +addRule(new EmptyHeading()); + + $violations = $scanner->scan('

'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_EMPTY_HEADING', $violations[0]->ruleId); + } + + public function testFlagsEmptyAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new EmptyHeading()); + + $violations = $scanner->scan('

'); + $this->assertCount(1, $violations); + } + + public function testPassesHeadingWithImageAlt(): void + { + $scanner = new Scanner(); + $scanner->addRule(new EmptyHeading()); + + $violations = $scanner->scan('

Company Name

'); + $this->assertCount(0, $violations); + } + + public function testPassesHeadingWithSvgTitle(): void + { + $scanner = new Scanner(); + $scanner->addRule(new EmptyHeading()); + + $violations = $scanner->scan('

Dashboard

'); + $this->assertCount(0, $violations); + } + + public function testPassesHeadingWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new EmptyHeading()); + + $violations = $scanner->scan('

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/FocusNotObscuredTest.php b/tests/Rules/FocusNotObscuredTest.php new file mode 100644 index 0000000..aa61259 --- /dev/null +++ b/tests/Rules/FocusNotObscuredTest.php @@ -0,0 +1,33 @@ +addRule(new FocusNotObscured()); + + $html = '
Navbar
'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_2_4_11_FOCUS_NOT_OBSCURED', $violations[0]->ruleId); + } + + public function testPassesStaticElements(): void + { + $scanner = new Scanner(); + $scanner->addRule(new FocusNotObscured()); + + $html = '
Navbar
'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/FormLabelTest.php b/tests/Rules/FormLabelTest.php new file mode 100644 index 0000000..47dffae --- /dev/null +++ b/tests/Rules/FormLabelTest.php @@ -0,0 +1,47 @@ +addRule(new FormLabel()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_LABEL', $violations[0]->ruleId); + } + + public function testFlagsEmptyAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new FormLabel()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + } + + public function testPassesInputWithAssociatedLabelFor(): void + { + $scanner = new Scanner(); + $scanner->addRule(new FormLabel()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesInputWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new FormLabel()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/HeadingOrderTest.php b/tests/Rules/HeadingOrderTest.php new file mode 100644 index 0000000..0ef195f --- /dev/null +++ b/tests/Rules/HeadingOrderTest.php @@ -0,0 +1,42 @@ +addRule(new HeadingOrder()); + + $violations = $scanner->scan('

Main Heading

Skipped Subheading

'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_HEADING', $violations[0]->ruleId); + } + + public function testPassesSequentialHeadings(): void + { + $scanner = new Scanner(); + $scanner->addRule(new HeadingOrder()); + + $violations = $scanner->scan('

Main Heading

Section

Sub-section

'); + $this->assertCount(0, $violations); + } + + public function testIsStatelessAcrossMultipleScans(): void + { + $scanner = new Scanner(); + $scanner->addRule(new HeadingOrder()); + + // First scan ends with H3 + $scanner->scan('

H1

H2

H3

'); + + // Second scan starts with H1, should not flag jump from previous scan's H3 + $violations = $scanner->scan('

Fresh Document H1

H2

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/HtmlDirValidTest.php b/tests/Rules/HtmlDirValidTest.php new file mode 100644 index 0000000..4688c41 --- /dev/null +++ b/tests/Rules/HtmlDirValidTest.php @@ -0,0 +1,29 @@ +addRule(new HtmlDirValid()); + + $violations = $scanner->scan('

Paragraph

'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_2_HTML_DIR', $violations[0]->ruleId); + } + + public function testPassesValidDir(): void + { + $scanner = new Scanner(); + $scanner->addRule(new HtmlDirValid()); + + $violations = $scanner->scan('

Arabic text

English

Auto

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/HtmlSyntaxValidTest.php b/tests/Rules/HtmlSyntaxValidTest.php new file mode 100644 index 0000000..f1d778b --- /dev/null +++ b/tests/Rules/HtmlSyntaxValidTest.php @@ -0,0 +1,29 @@ +addRule(new HtmlSyntaxValid()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_1_HTML_PARSING_SYNTAX', $violations[0]->ruleId); + } + + public function testPassesValidHtml(): void + { + $scanner = new Scanner(); + $scanner->addRule(new HtmlSyntaxValid()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/IframeTitleTest.php b/tests/Rules/IframeTitleTest.php new file mode 100644 index 0000000..003dac6 --- /dev/null +++ b/tests/Rules/IframeTitleTest.php @@ -0,0 +1,47 @@ +addRule(new IframeTitle()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_IFRAME_TITLE', $violations[0]->ruleId); + } + + public function testPassesIframeWithTitle(): void + { + $scanner = new Scanner(); + $scanner->addRule(new IframeTitle()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesIframeWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new IframeTitle()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesAriaHiddenIframe(): void + { + $scanner = new Scanner(); + $scanner->addRule(new IframeTitle()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/InputAutocompleteAppropriateTest.php b/tests/Rules/InputAutocompleteAppropriateTest.php new file mode 100644 index 0000000..f5a5b07 --- /dev/null +++ b/tests/Rules/InputAutocompleteAppropriateTest.php @@ -0,0 +1,33 @@ +addRule(new InputAutocompleteAppropriate()); + + $html = '
'; + $violations = $scanner->scan($html); + + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_5_AUTOCOMPLETE_APPROPRIATE', $violations[0]->ruleId); + } + + public function testPassesAppropriateAutocompleteType(): void + { + $scanner = new Scanner(); + $scanner->addRule(new InputAutocompleteAppropriate()); + + $html = '
'; + $violations = $scanner->scan($html); + + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/InputMissingAutocompleteTest.php b/tests/Rules/InputMissingAutocompleteTest.php index b00e6ed..7702daa 100644 --- a/tests/Rules/InputMissingAutocompleteTest.php +++ b/tests/Rules/InputMissingAutocompleteTest.php @@ -8,22 +8,32 @@ use YakNet\AccessibilityConsole\Rules\InputMissingAutocomplete; class InputMissingAutocompleteTest extends TestCase { - public function testFlagsPersonalDataInputWithoutAutocomplete(): void + public function testFlagsPasswordWithAutocompleteOff(): void { $scanner = new Scanner(); $scanner->addRule(new InputMissingAutocomplete()); - $violations = $scanner->scan('
'); + $violations = $scanner->scan('
'); $this->assertCount(1, $violations); - $this->assertSame('WCAG_1_3_5_INPUT_AUTOCOMPLETE', $violations[0]->ruleId); + $this->assertSame('WCAG_3_3_8_ACCESSIBLE_AUTH', $violations[0]->ruleId); } - public function testPassesPersonalDataInputWithAutocomplete(): void + public function testFlagsPasteBlockingOnInput(): void { $scanner = new Scanner(); $scanner->addRule(new InputMissingAutocomplete()); - $violations = $scanner->scan('
'); + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_3_3_8_ACCESSIBLE_AUTH', $violations[0]->ruleId); + } + + public function testPassesAccessiblePasswordInput(): void + { + $scanner = new Scanner(); + $scanner->addRule(new InputMissingAutocomplete()); + + $violations = $scanner->scan('
'); $this->assertCount(0, $violations); } } diff --git a/tests/Rules/MediaControlsTest.php b/tests/Rules/MediaControlsTest.php new file mode 100644 index 0000000..ae46cea --- /dev/null +++ b/tests/Rules/MediaControlsTest.php @@ -0,0 +1,38 @@ +addRule(new MediaControls()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_4_2_MEDIA_CONTROLS', $violations[0]->ruleId); + } + + public function testPassesAudioWithControls(): void + { + $scanner = new Scanner(); + $scanner->addRule(new MediaControls()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesBackgroundMutedAutoplayVideo(): void + { + $scanner = new Scanner(); + $scanner->addRule(new MediaControls()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/MetaCharsetPresentTest.php b/tests/Rules/MetaCharsetPresentTest.php new file mode 100644 index 0000000..e7df0de --- /dev/null +++ b/tests/Rules/MetaCharsetPresentTest.php @@ -0,0 +1,29 @@ +addRule(new MetaCharsetPresent()); + + $violations = $scanner->scan('Test Page

Hello

'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_3_1_1_META_CHARSET', $violations[0]->ruleId); + } + + public function testPassesWhenMetaCharsetPresent(): void + { + $scanner = new Scanner(); + $scanner->addRule(new MetaCharsetPresent()); + + $violations = $scanner->scan('Test Page

Hello

'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/MeterAccessibilityTest.php b/tests/Rules/MeterAccessibilityTest.php new file mode 100644 index 0000000..65d3f99 --- /dev/null +++ b/tests/Rules/MeterAccessibilityTest.php @@ -0,0 +1,29 @@ +addRule(new MeterAccessibility()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_METER_ACCESSIBLE', $violations[0]->ruleId); + } + + public function testPassesMeterWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new MeterAccessibility()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/NestedInteractiveTest.php b/tests/Rules/NestedInteractiveTest.php new file mode 100644 index 0000000..01d3ebc --- /dev/null +++ b/tests/Rules/NestedInteractiveTest.php @@ -0,0 +1,38 @@ +addRule(new NestedInteractive()); + + $violations = $scanner->scan('
Outer Inner
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_4_1_2_NESTED_INTERACTIVE', $violations[0]->ruleId); + } + + public function testFlagsButtonInsideLink(): void + { + $scanner = new Scanner(); + $scanner->addRule(new NestedInteractive()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + } + + public function testPassesNonNestedInteractives(): void + { + $scanner = new Scanner(); + $scanner->addRule(new NestedInteractive()); + + $violations = $scanner->scan('
Link
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/RoleImgAltTest.php b/tests/Rules/RoleImgAltTest.php new file mode 100644 index 0000000..d4b1b45 --- /dev/null +++ b/tests/Rules/RoleImgAltTest.php @@ -0,0 +1,38 @@ +addRule(new RoleImgAlt()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_1_1_ROLE_IMG', $violations[0]->ruleId); + } + + public function testPassesRoleImgWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new RoleImgAlt()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesRoleImgWithAriaHidden(): void + { + $scanner = new Scanner(); + $scanner->addRule(new RoleImgAlt()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/SelectValidChildrenTest.php b/tests/Rules/SelectValidChildrenTest.php new file mode 100644 index 0000000..de2709c --- /dev/null +++ b/tests/Rules/SelectValidChildrenTest.php @@ -0,0 +1,29 @@ +addRule(new SelectValidChildren()); + + $violations = $scanner->scan(''); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_SELECT_CHILDREN', $violations[0]->ruleId); + } + + public function testPassesValidSelectOptionsAndOptgroups(): void + { + $scanner = new Scanner(); + $scanner->addRule(new SelectValidChildren()); + + $violations = $scanner->scan(''); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/TableHeaderNotEmptyTest.php b/tests/Rules/TableHeaderNotEmptyTest.php new file mode 100644 index 0000000..6ecae19 --- /dev/null +++ b/tests/Rules/TableHeaderNotEmptyTest.php @@ -0,0 +1,38 @@ +addRule(new TableHeaderNotEmpty()); + + $violations = $scanner->scan('
Value
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_TH_NOT_EMPTY', $violations[0]->ruleId); + } + + public function testPassesTableHeaderWithText(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TableHeaderNotEmpty()); + + $violations = $scanner->scan('
NameJohn
'); + $this->assertCount(0, $violations); + } + + public function testPassesTableHeaderWithAriaLabel(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TableHeaderNotEmpty()); + + $violations = $scanner->scan('
Edit
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/TableHeadersAttributeValidTest.php b/tests/Rules/TableHeadersAttributeValidTest.php new file mode 100644 index 0000000..4e6127b --- /dev/null +++ b/tests/Rules/TableHeadersAttributeValidTest.php @@ -0,0 +1,29 @@ +addRule(new TableHeadersAttributeValid()); + + $violations = $scanner->scan('
Header 1
Cell
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_3_1_TABLE_HEADERS_ATTR', $violations[0]->ruleId); + } + + public function testPassesValidHeaderId(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TableHeadersAttributeValid()); + + $violations = $scanner->scan('
Header 1
Cell
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/TargetSizeMinimumTest.php b/tests/Rules/TargetSizeMinimumTest.php new file mode 100644 index 0000000..2faed21 --- /dev/null +++ b/tests/Rules/TargetSizeMinimumTest.php @@ -0,0 +1,38 @@ +addRule(new TargetSizeMinimum()); + + $violations = $scanner->scan('
'); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_2_5_8_TARGET_SIZE', $violations[0]->ruleId); + } + + public function testPassesAdequateTargetSize(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TargetSizeMinimum()); + + $violations = $scanner->scan('
'); + $this->assertCount(0, $violations); + } + + public function testPassesElementsWithoutInlineSize(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TargetSizeMinimum()); + + $violations = $scanner->scan('
Normal Link
'); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Rules/TrackValidationTest.php b/tests/Rules/TrackValidationTest.php new file mode 100644 index 0000000..343c81e --- /dev/null +++ b/tests/Rules/TrackValidationTest.php @@ -0,0 +1,38 @@ +addRule(new TrackValidation()); + + $violations = $scanner->scan(''); + $this->assertCount(1, $violations); + $this->assertSame('WCAG_1_2_2_TRACK_VALIDATION', $violations[0]->ruleId); + } + + public function testFlagsMissingSrclangOnCaptions(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TrackValidation()); + + $violations = $scanner->scan(''); + $this->assertCount(1, $violations); + } + + public function testPassesValidTrack(): void + { + $scanner = new Scanner(); + $scanner->addRule(new TrackValidation()); + + $violations = $scanner->scan(''); + $this->assertCount(0, $violations); + } +} diff --git a/tests/Syntax/HtmlSyntaxLinterTest.php b/tests/Syntax/HtmlSyntaxLinterTest.php new file mode 100644 index 0000000..229cf2b --- /dev/null +++ b/tests/Syntax/HtmlSyntaxLinterTest.php @@ -0,0 +1,64 @@ +linter = new HtmlSyntaxLinter(); + } + + public function testDetectsDuplicateAttributes(): void + { + $html = '
'; + $issues = $this->linter->lint($html); + + $this->assertNotEmpty($issues); + $duplicateIssues = array_filter($issues, fn($i) => $i->code === 'SYNTAX_DUPLICATE_ATTR'); + $this->assertCount(1, $duplicateIssues); + } + + public function testDetectsInvalidSelfClosingOnNonVoidElement(): void + { + $html = '

Text

'; + $issues = $this->linter->lint($html); + + $this->assertNotEmpty($issues); + $selfCloseIssues = array_filter($issues, fn($i) => $i->code === 'SYNTAX_INVALID_SELF_CLOSING'); + $this->assertCount(1, $selfCloseIssues); + } + + public function testDetectsUnclosedComments(): void + { + $html = '