""" Automated WCAG 2.1 AA Compliance Tests This module provides automated accessibility testing using axe-core via Selenium. Tests verify WCAG 2.1 AA compliance across key pages of the ThrillWiki application. Requirements: - selenium - axe-selenium-python - Chrome browser with ChromeDriver Installation: pip install selenium axe-selenium-python Usage: python manage.py test backend.tests.accessibility Note: These tests require a running server and browser. They are skipped if dependencies are not installed or if running in CI without browser support. """ import os import unittest from django.test import TestCase, LiveServerTestCase, override_settings from django.urls import reverse from django.contrib.auth import get_user_model # Check if selenium and axe are available try: from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC HAS_SELENIUM = True except ImportError: HAS_SELENIUM = False try: from axe_selenium_python import Axe HAS_AXE = True except ImportError: HAS_AXE = False User = get_user_model() def skip_if_no_browser(): """Decorator to skip tests if browser dependencies are not available.""" if not HAS_SELENIUM: return unittest.skip("Selenium not installed") if not HAS_AXE: return unittest.skip("axe-selenium-python not installed") if os.environ.get('CI') and not os.environ.get('BROWSER_TESTS'): return unittest.skip("Browser tests disabled in CI") return lambda func: func class AccessibilityTestMixin: """Mixin providing common accessibility testing utilities.""" def run_axe_audit(self, url_name=None, url=None): """ Run axe accessibility audit on a page. Args: url_name: Django URL name to reverse url: Full URL (alternative to url_name) Returns: dict: Axe results containing violations and passes """ if url_name: url = f'{self.live_server_url}{reverse(url_name)}' elif not url: raise ValueError("Either url_name or url must be provided") self.driver.get(url) WebDriverWait(self.driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, "body")) ) axe = Axe(self.driver) axe.inject() results = axe.run() return results def assert_no_critical_violations(self, results, page_name="page"): """ Assert no critical or serious accessibility violations. Args: results: Axe audit results page_name: Name of page for error messages """ critical_violations = [ v for v in results.get('violations', []) if v.get('impact') in ('critical', 'serious') ] if critical_violations: violation_details = "\n".join([ f"- {v['id']}: {v['description']} (impact: {v['impact']})" for v in critical_violations ]) self.fail( f"Critical accessibility violations found on {page_name}:\n" f"{violation_details}" ) def assert_wcag_aa_compliant(self, results, page_name="page"): """ Assert WCAG 2.1 AA compliance (no violations at all). Args: results: Axe audit results page_name: Name of page for error messages """ violations = results.get('violations', []) if violations: violation_details = "\n".join([ f"- {v['id']}: {v['description']} (impact: {v['impact']})" for v in violations ]) self.fail( f"WCAG 2.1 AA violations found on {page_name}:\n" f"{violation_details}" ) @skip_if_no_browser() @override_settings(DEBUG=True) class WCAGComplianceTests(AccessibilityTestMixin, LiveServerTestCase): """ Automated WCAG 2.1 AA compliance tests for ThrillWiki. These tests use axe-core via Selenium to automatically detect accessibility issues across key pages. """ @classmethod def setUpClass(cls): super().setUpClass() # Configure Chrome for headless testing chrome_options = Options() chrome_options.add_argument('--headless') chrome_options.add_argument('--no-sandbox') chrome_options.add_argument('--disable-dev-shm-usage') chrome_options.add_argument('--disable-gpu') chrome_options.add_argument('--window-size=1920,1080') try: cls.driver = webdriver.Chrome(options=chrome_options) cls.driver.implicitly_wait(10) except Exception as e: raise unittest.SkipTest(f"Chrome WebDriver not available: {e}") @classmethod def tearDownClass(cls): if hasattr(cls, 'driver'): cls.driver.quit() super().tearDownClass() def test_homepage_accessibility(self): """Test homepage WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='home') self.assert_no_critical_violations(results, "homepage") def test_park_list_accessibility(self): """Test park list page WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='parks:park_list') self.assert_no_critical_violations(results, "park list") def test_ride_list_accessibility(self): """Test ride list page WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='rides:global_ride_list') self.assert_no_critical_violations(results, "ride list") def test_manufacturer_list_accessibility(self): """Test manufacturer list page WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='rides:manufacturer_list') self.assert_no_critical_violations(results, "manufacturer list") def test_login_page_accessibility(self): """Test login page WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='account_login') self.assert_no_critical_violations(results, "login page") def test_signup_page_accessibility(self): """Test signup page WCAG 2.1 AA compliance.""" results = self.run_axe_audit(url_name='account_signup') self.assert_no_critical_violations(results, "signup page") class HTMLAccessibilityTests(TestCase): """ Unit tests for accessibility features in rendered HTML. These tests don't require a browser and verify that templates render correct accessibility attributes. """ def test_homepage_has_main_landmark(self): """Verify homepage has a main landmark.""" response = self.client.get(reverse('home')) self.assertContains(response, ']*>', content) for img in img_tags: self.assertIn( 'alt=', img, f"Image missing alt attribute: {img[:100]}" ) def test_form_fields_have_labels(self): """Verify form fields have associated labels.""" response = self.client.get(reverse('account_login')) content = response.content.decode('utf-8') # Find input elements (excluding hidden and submit) import re inputs = re.findall( r']*type=["\'](?!hidden|submit)[^"\']*["\'][^>]*>', content ) for inp in inputs: # Each input should have id attribute for label association self.assertTrue( 'id=' in inp or 'aria-label' in inp, f"Input missing id or aria-label: {inp[:100]}" ) def test_buttons_are_accessible(self): """Verify buttons have accessible names.""" response = self.client.get(reverse('home')) content = response.content.decode('utf-8') import re # Find button elements buttons = re.findall(r']*>.*?', content, re.DOTALL) for button in buttons: # Button should have text content or aria-label has_text = bool(re.search(r'>([^<]+)<', button)) has_aria = 'aria-label' in button self.assertTrue( has_text or has_aria, f"Button missing accessible name: {button[:100]}" ) class KeyboardNavigationTests(TestCase): """ Tests for keyboard navigation requirements. These tests verify that interactive elements are keyboard accessible. """ def test_interactive_elements_are_focusable(self): """Verify interactive elements don't have tabindex=-1.""" response = self.client.get(reverse('home')) content = response.content.decode('utf-8') # Links and buttons should not have tabindex=-1 (unless intentionally hidden) import re problematic = re.findall( r'<(a|button)[^>]*tabindex=["\']?-1["\']?[^>]*>', content ) # Filter out elements that are legitimately hidden for elem in problematic: self.assertIn( 'aria-hidden', elem, f"Interactive element has tabindex=-1 without aria-hidden: {elem}" ) def test_modals_have_escape_handler(self): """Verify modal templates include escape key handling.""" from django.template.loader import get_template template = get_template('components/modals/modal_inner.html') source = template.template.source self.assertIn( 'escape', source.lower(), "Modal should handle Escape key" ) def test_dropdowns_have_keyboard_support(self): """Verify dropdown menus support keyboard navigation.""" response = self.client.get(reverse('home')) content = response.content.decode('utf-8') # Check for aria-expanded on dropdown triggers if 'dropdown' in content.lower() or 'menu' in content.lower(): self.assertIn( 'aria-expanded', content, "Dropdown should have aria-expanded attribute" ) class ARIAAttributeTests(TestCase): """ Tests for correct ARIA attribute usage. """ def test_modal_has_dialog_role(self): """Verify modal has role=dialog.""" from django.template.loader import get_template template = get_template('components/modals/modal_inner.html') source = template.template.source self.assertIn( 'role="dialog"', source, "Modal should have role=dialog" ) def test_modal_has_aria_modal(self): """Verify modal has aria-modal=true.""" from django.template.loader import get_template template = get_template('components/modals/modal_inner.html') source = template.template.source self.assertIn( 'aria-modal="true"', source, "Modal should have aria-modal=true" ) def test_breadcrumb_has_navigation_role(self): """Verify breadcrumbs use nav element with aria-label.""" from django.template.loader import get_template template = get_template('components/navigation/breadcrumbs.html') source = template.template.source self.assertIn( '