feat(v3.3.0): enterprise presets, badges, notifications, rule controller, watch mode, offline healer, and WCAG 2.2 ruleset

This commit is contained in:
Enes Yakıştır
2026-09-12 10:12:05 +03:00
parent 2de1f34878
commit ef8ccb9f75
43 changed files with 2598 additions and 81 deletions
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\AutoFix;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LocalHealerTest extends TestCase
{
private LocalHealer $healer;
protected function setUp(): void
{
$this->healer = new LocalHealer();
}
public function testHealsMissingImgAlt(): void
{
$violation = new Violation(
'WCAG_1_1_1_IMG_ALT',
'Image missing alt',
Severity::ERROR,
WCAGStandard::A,
'<img src="photo.jpg">'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringContainsString('FIX: <img alt="" src="photo.jpg">', $healed);
}
public function testHealsDecorativeSvg(): void
{
$violation = new Violation(
'WCAG_1_1_1_SVG',
'SVG needs accessible text',
Severity::ERROR,
WCAGStandard::A,
'<svg class="icon"><path d="..."/></svg>'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringContainsString('FIX: <svg aria-hidden="true" class="icon">', $healed);
}
public function testHealsTargetBlankLinkRel(): void
{
$violation = new Violation(
'WCAG_LINK_TARGET_BLANK',
'Target blank missing noopener',
Severity::WARNING,
WCAGStandard::A,
'<a href="https://example.com" target="_blank">External</a>'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringContainsString('rel="noopener noreferrer"', $healed);
}
public function testHealsPositiveTabindex(): void
{
$violation = new Violation(
'WCAG_2_4_3_TABINDEX',
'Avoid positive tabindex',
Severity::WARNING,
WCAGStandard::A,
'<button tabindex="5">Submit</button>'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringContainsString('tabindex="0"', $healed);
}
public function testHealsInvalidTableScope(): void
{
$violation = new Violation(
'WCAG_1_3_1_TABLE_SCOPE',
'Scope only on headers',
Severity::ERROR,
WCAGStandard::A,
'<td scope="col">Data cell</td>'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringNotContainsString('scope=', $healed);
}
public function testHealsMetaRefresh(): void
{
$violation = new Violation(
'WCAG_2_2_1_META_REFRESH',
'Meta refresh banned',
Severity::ERROR,
WCAGStandard::A,
'<meta http-equiv="refresh" content="30">'
);
$healed = $this->healer->heal($violation);
$this->assertNotNull($healed);
$this->assertStringContainsString('<!-- <meta http-equiv="refresh" content="30"> (Removed', $healed);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Badges;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Badges\BadgeGenerator;
class BadgeGeneratorTest extends TestCase
{
public function testGeneratesValidScoreBadgeSvg(): void
{
$svg = BadgeGenerator::generateScoreBadge(95);
$this->assertStringContainsString('<svg', $svg);
$this->assertStringContainsString('</svg>', $svg);
$this->assertStringContainsString('accessibility', $svg);
$this->assertStringContainsString('95/100', $svg);
$this->assertStringContainsString('#10b981', $svg); // green color for >= 90
}
public function testGeneratesWarningScoreBadgeSvg(): void
{
$svg = BadgeGenerator::generateScoreBadge(65);
$this->assertStringContainsString('65/100', $svg);
$this->assertStringContainsString('#ef4444', $svg); // red color for < 70
}
public function testGeneratesComplianceBadge(): void
{
$svg = BadgeGenerator::generateComplianceBadge('WCAG 2.1 AA', true);
$this->assertStringContainsString('WCAG 2.1 AA', $svg);
$this->assertStringContainsString('passed', $svg);
}
public function testGeneratesViolationsBadge(): void
{
$svg = BadgeGenerator::generateViolationsBadge(0);
$this->assertStringContainsString('0 issues', $svg);
$svgIssues = BadgeGenerator::generateViolationsBadge(4);
$this->assertStringContainsString('4 issues', $svgIssues);
}
public function testGeneratesBadgeWithCustomOptions(): void
{
$options = new \YakNet\AccessibilityConsole\Badges\BadgeOptions(
label: 'custom-a11y',
leftColor: '#003366',
rightColor: '#00cc66',
borderRadius: 6
);
$svg = BadgeGenerator::generateScoreBadge(88, $options);
$this->assertStringContainsString('custom-a11y', $svg);
$this->assertStringContainsString('#003366', $svg);
$this->assertStringContainsString('#00cc66', $svg);
$this->assertStringContainsString('rx="6"', $svg);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Console;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
class RulesCommandTest extends TestCase
{
public function testListAllRules(): void
{
$command = new RulesCommand();
$tester = new CommandTester($command);
$exitCode = $tester->execute([]);
$this->assertSame(0, $exitCode);
$output = $tester->getDisplay();
$this->assertStringContainsString('Accessibility Console - Available Rules', $output);
$this->assertStringContainsString('Total rules available: 130', $output);
}
public function testFilterRulesBySearch(): void
{
$command = new RulesCommand();
$tester = new CommandTester($command);
$exitCode = $tester->execute([
'--search' => 'contrast',
]);
$this->assertSame(0, $exitCode);
$output = $tester->getDisplay();
$this->assertStringContainsString('WCAG_1_4_3_CONTRAST', $output);
$this->assertStringContainsString('Showing', $output);
}
public function testFilterRulesByStandardAndLevel(): void
{
$command = new RulesCommand();
$tester = new CommandTester($command);
$exitCode = $tester->execute([
'--standard' => 'AA',
]);
$this->assertSame(0, $exitCode);
$output = $tester->getDisplay();
$this->assertStringContainsString('AA', $output);
$this->assertStringContainsString('Showing', $output);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Console;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Tester\CommandTester;
use YakNet\AccessibilityConsole\Console\Command\WatchCommand;
class WatchCommandTest extends TestCase
{
public function testWatchCommandRunsInitialScanAndExitsWithMaxIterations(): void
{
$command = new WatchCommand();
$tester = new CommandTester($command);
$exitCode = $tester->execute([
'target' => 'demo/views-fixed/home.php',
'--max-iterations' => '1',
'--interval' => '1',
]);
$this->assertSame(0, $exitCode);
$output = $tester->getDisplay();
$this->assertStringContainsString('Accessibility Console - Real-Time Watch Mode', $output);
$this->assertStringContainsString('Initial Scan', $output);
$this->assertStringContainsString('Health Score:', $output);
}
public function testDetectChangedFiles(): void
{
$command = new WatchCommand();
$old = [
'file1.php' => 100,
'file2.php' => 200,
];
$new = [
'file1.php' => 100,
'file2.php' => 205, // modified
'file3.php' => 300, // added
];
$changed = $command->detectChangedFiles($old, $new);
$this->assertContains('file2.php', $changed);
$this->assertContains('file3.php', $changed);
$this->assertNotContains('file1.php', $changed);
}
}
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Notifications;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Notifications\NotificationDispatcher;
use YakNet\AccessibilityConsole\Notifications\NotificationPayload;
use YakNet\AccessibilityConsole\Notifications\NotifierInterface;
class NotificationDispatcherTest extends TestCase
{
public function testDispatchToMultipleNotifiers(): void
{
$dispatcher = new NotificationDispatcher();
$mockSlack = $this->createMock(NotifierInterface::class);
$mockSlack->expects($this->once())
->method('send')
->willReturn(true);
$mockDiscord = $this->createMock(NotifierInterface::class);
$mockDiscord->expects($this->once())
->method('send')
->willReturn(true);
$dispatcher->addNotifier('slack', $mockSlack);
$dispatcher->addNotifier('discord', $mockDiscord);
$this->assertTrue($dispatcher->hasNotifiers());
$payload = new NotificationPayload('https://example.com', 95, 2, 0, 2);
$results = $dispatcher->dispatch($payload);
$this->assertTrue($results['slack']);
$this->assertTrue($results['discord']);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Presets;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Presets\PresetRegistry;
use YakNet\AccessibilityConsole\Presets\Section508Preset;
use YakNet\AccessibilityConsole\Presets\En301549Preset;
use YakNet\AccessibilityConsole\Presets\ECommercePreset;
class PresetRegistryTest extends TestCase
{
public function testRegistryLoadsDefaultPresets(): void
{
$registry = new PresetRegistry();
$all = $registry->all();
$this->assertArrayHasKey('section508', $all);
$this->assertArrayHasKey('en301549', $all);
$this->assertArrayHasKey('ecommerce', $all);
}
public function testGetPresetByName(): void
{
$registry = new PresetRegistry();
$preset = $registry->get('section508');
$this->assertInstanceOf(Section508Preset::class, $preset);
$this->assertSame(3, $preset->getLevel());
$ecommerce = $registry->get('ecommerce');
$this->assertInstanceOf(ECommercePreset::class, $ecommerce);
$this->assertContains('WCAG_3_3_7_REDUNDANT_ENTRY', $ecommerce->getIncludedRules());
}
public function testGetNonExistentPresetReturnsNull(): void
{
$registry = new PresetRegistry();
$this->assertNull($registry->get('non_existent'));
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Reporting;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
use YakNet\AccessibilityConsole\Reporting\JunitFormatter;
class JunitFormatterTest extends TestCase
{
public function testRendersValidJunitXmlWithViolations(): void
{
$formatter = new JunitFormatter();
$violations = [
new Violation(
'WCAG_1_1_1_IMG_ALT',
'Images must have alternative text.',
Severity::ERROR,
WCAGStandard::A,
'<img src="photo.png">',
['file' => 'views/index.php', 'line' => 10, 'column' => 2]
),
];
$xml = $formatter->render($violations);
$this->assertStringContainsString('<?xml version="1.0" encoding="UTF-8"?>', $xml);
$this->assertStringContainsString('<testsuites', $xml);
$this->assertStringContainsString('failures="1"', $xml);
$this->assertStringContainsString('<failure', $xml);
$this->assertStringContainsString('WCAG_1_1_1_IMG_ALT', $xml);
// Verify valid XML parsing
$dom = new \DOMDocument();
$this->assertTrue($dom->loadXML($xml));
}
public function testRendersCleanXmlWhenEmpty(): void
{
$formatter = new JunitFormatter();
$input = new ArrayInput([]);
$output = new BufferedOutput();
$io = new SymfonyStyle($input, $output);
$exitCode = $formatter->format([], 0, $io);
$this->assertSame(0, $exitCode);
$xml = $output->fetch();
$this->assertStringContainsString('failures="0"', $xml);
$this->assertStringContainsString('AccessibilityCompliant', $xml);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Reporting;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
use YakNet\AccessibilityConsole\Reporting\SarifFormatter;
class SarifFormatterTest extends TestCase
{
public function testRendersValidSarifJson(): void
{
$formatter = new SarifFormatter();
$violations = [
new Violation(
'WCAG_1_1_1_IMG_ALT',
'Images must have alternative text.',
Severity::ERROR,
WCAGStandard::A,
'<img src="test.jpg">',
['file' => 'templates/home.blade.php', 'line' => 14, 'column' => 5],
'Add alt="" attribute'
),
new Violation(
'WCAG_1_4_3_CONTRAST',
'Insufficient color contrast.',
Severity::WARNING,
WCAGStandard::AA,
'<span style="color:#ccc">Text</span>',
['file' => 'templates/home.blade.php', 'line' => 20, 'column' => 1]
),
];
$json = $formatter->render($violations);
$this->assertJson($json);
$decoded = json_decode($json, true);
$this->assertIsArray($decoded);
$this->assertSame('2.1.0', $decoded['version']);
$this->assertSame('YakNet Accessibility Console', $decoded['runs'][0]['tool']['driver']['name']);
$this->assertCount(2, $decoded['runs'][0]['results']);
$firstResult = $decoded['runs'][0]['results'][0];
$this->assertSame('WCAG_1_1_1_IMG_ALT', $firstResult['ruleId']);
$this->assertSame('error', $firstResult['level']);
$this->assertSame('templates/home.blade.php', $firstResult['locations'][0]['physicalLocation']['artifactLocation']['uri']);
$this->assertSame(14, $firstResult['locations'][0]['physicalLocation']['region']['startLine']);
}
public function testFormatOutputsToSymfonyStyle(): void
{
$formatter = new SarifFormatter();
$violations = [];
$input = new ArrayInput([]);
$output = new BufferedOutput();
$io = new SymfonyStyle($input, $output);
$exitCode = $formatter->format($violations, 0, $io);
$this->assertSame(0, $exitCode);
$content = $output->fetch();
$this->assertJson($content);
$decoded = json_decode($content, true);
$this->assertIsArray($decoded);
$this->assertEmpty($decoded['runs'][0]['results']);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\LabelInName;
class LabelInNameTest extends TestCase
{
public function testFlagsButtonWhoseAriaLabelDoesNotContainVisibleText(): void
{
$scanner = new Scanner();
$scanner->addRule(new LabelInName());
$html = '<button aria-label="Send Form">Submit</button>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_2_5_3_LABEL_IN_NAME', $violations[0]->ruleId);
$this->assertStringContainsString('Submit', $violations[0]->message);
}
public function testPassesButtonWhoseAriaLabelContainsVisibleText(): void
{
$scanner = new Scanner();
$scanner->addRule(new LabelInName());
$html = '<button aria-label="Submit this inquiry">Submit</button>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
public function testPassesButtonWithoutExplicitAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new LabelInName());
$html = '<button>Click Here</button>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
public function testFlagsLinkWhoseAriaLabelDiffersFromVisibleText(): void
{
$scanner = new Scanner();
$scanner->addRule(new LabelInName());
$html = '<a href="/contact" aria-label="Reach out to support">Contact Us</a>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\RedundantEntry;
class RedundantEntryTest extends TestCase
{
public function testFlagsFormWithShippingAndBillingWithoutSameAsCheckbox(): void
{
$scanner = new Scanner();
$scanner->addRule(new RedundantEntry());
$html = '<form>
<input type="text" name="shipping_address" id="shipping_address">
<input type="text" name="shipping_city" id="shipping_city">
<input type="text" name="billing_address" id="billing_address">
<input type="text" name="billing_city" id="billing_city">
</form>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_3_3_7_REDUNDANT_ENTRY', $violations[0]->ruleId);
}
public function testPassesFormWithSameAsShippingCheckbox(): void
{
$scanner = new Scanner();
$scanner->addRule(new RedundantEntry());
$html = '<form>
<input type="text" name="shipping_address" id="shipping_address">
<input type="text" name="shipping_city" id="shipping_city">
<label><input type="checkbox" name="same_as_shipping"> Billing same as shipping</label>
<input type="text" name="billing_address" id="billing_address">
<input type="text" name="billing_city" id="billing_city">
</form>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
use YakNet\AccessibilityConsole\Rules\RuleController;
class RuleControllerTest extends TestCase
{
public function testInitializesAndRetrievesRule(): void
{
$controller = new RuleController();
$this->assertGreaterThanOrEqual(130, $controller->count());
$rule = $controller->getRule('WCAG_2_5_3_LABEL_IN_NAME');
$this->assertNotNull($rule);
}
public function testFilterByStandard(): void
{
$controller = new RuleController();
$aaRules = $controller->filterByStandard(WCAGStandard::AA);
$this->assertNotEmpty($aaRules);
foreach ($aaRules as $rule) {
$this->assertSame(WCAGStandard::AA, $rule->getStandard());
}
}
public function testFilterBySeverity(): void
{
$controller = new RuleController();
$errorRules = $controller->filterBySeverity(Severity::ERROR);
$this->assertNotEmpty($errorRules);
foreach ($errorRules as $rule) {
$this->assertSame(Severity::ERROR, $rule->getSeverity());
}
}
public function testExecuteRuleDirectlyOnDomElement(): void
{
$controller = new RuleController();
$dom = new \DOMDocument();
$dom->loadHTML('<button aria-label="Cancel">Submit</button>');
$button = $dom->getElementsByTagName('button')->item(0);
$this->assertInstanceOf(\DOMElement::class, $button);
$violation = $controller->executeRule('WCAG_2_5_3_LABEL_IN_NAME', $button);
$this->assertNotNull($violation);
$this->assertSame('WCAG_2_5_3_LABEL_IN_NAME', $violation->ruleId);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Rules\RuleExecutionStats;
class RuleExecutionStatsTest extends TestCase
{
public function testRecordAndRetrieveStats(): void
{
$stats = new RuleExecutionStats();
$stats->record('WCAG_1_1_1_IMG_ALT', 2.5, true);
$stats->record('WCAG_1_1_1_IMG_ALT', 1.5, false);
$stats->record('WCAG_1_4_3_CONTRAST', 10.0, true);
$all = $stats->getAllStats();
$this->assertSame(2, $all['WCAG_1_1_1_IMG_ALT']['invocations']);
$this->assertSame(4.0, $all['WCAG_1_1_1_IMG_ALT']['total_time_ms']);
$this->assertSame(1, $all['WCAG_1_1_1_IMG_ALT']['violations']);
$slowest = $stats->getSlowestRules(1);
$this->assertArrayHasKey('WCAG_1_4_3_CONTRAST', $slowest);
$this->assertSame(10.0, $slowest['WCAG_1_4_3_CONTRAST']['total_time_ms']);
}
public function testResetStats(): void
{
$stats = new RuleExecutionStats();
$stats->record('WCAG_1_1_1_IMG_ALT', 5.0, true);
$this->assertNotEmpty($stats->getAllStats());
$stats->reset();
$this->assertEmpty($stats->getAllStats());
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TextSpacing;
class TextSpacingTest extends TestCase
{
public function testFlagsConstrainedLineHeight(): void
{
$scanner = new Scanner();
$scanner->addRule(new TextSpacing());
$html = '<p style="line-height: 1; font-size: 16px;">This text has insufficient line height.</p>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_4_12_TEXT_SPACING', $violations[0]->ruleId);
}
public function testPassesAdequateLineHeight(): void
{
$scanner = new Scanner();
$scanner->addRule(new TextSpacing());
$html = '<p style="line-height: 1.6; font-size: 16px;">Proper line height content.</p>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
public function testFlagsFixedHeightWithOverflowHiddenOnText(): void
{
$scanner = new Scanner();
$scanner->addRule(new TextSpacing());
$html = '<div style="height: 30px; overflow: hidden;"><p>This is a long paragraph that might be clipped when spacing is enlarged by the user in accessible mode.</p></div>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
}
}