""" Accessibility Backend Tests Tests for HTML structure and semantic markup accessibility """ import re import pytest from bs4 import BeautifulSoup class TestHTMLAccessibility: """Test HTML structure for accessibility compliance""" @pytest.fixture def research_page_html(self, authenticated_client): """Fetch the research page HTML""" response = authenticated_client.get("/") assert response.status_code == 200 return BeautifulSoup(response.data, "html.parser") def test_form_has_proper_labels(self, research_page_html): """Test that all form inputs have proper labels""" # audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: STRENGTHEN). soup = research_page_html # Find all input elements that should have labels inputs = soup.find_all(["input", "textarea", "select"]) # Track which inputs need labels inputs_needing_labels = [] for input_element in inputs: input_id = input_element.get("id") input_type = input_element.get("type", "text") input_name = input_element.get("name", "") # Skip hidden inputs, buttons, and CSRF tokens if ( input_type in ["hidden", "submit", "button"] or input_name == "csrf_token" ): continue # Check for associated label if input_id: label = soup.find("label", {"for": input_id}) if label is None: inputs_needing_labels.append( f"Input with id '{input_id}' (type: {input_type})" ) # Allow some flexibility - not all inputs may need labels in modern UI if len(inputs_needing_labels) > 0: print( f"Note: {len(inputs_needing_labels)} inputs without labels found: {inputs_needing_labels}" ) def test_radio_button_structure(self, research_page_html): """Test that radio buttons are properly structured""" soup = research_page_html # Find radio button group - might be named differently radio_inputs = soup.find_all("input", {"type": "radio"}) if len(radio_inputs) == 0: pytest.skip( "No radio buttons found on page - might be using different UI" ) # Check that radio buttons have proper structure for radio in radio_inputs: assert radio.get("name") is not None, ( "Radio button should have a name attribute" ) radio_id = radio.get("id") if radio_id: # Check for associated label label = soup.find("label", {"for": radio_id}) assert label is not None, ( f"Radio button with id '{radio_id}' should have a label" ) def test_fieldset_and_legend(self, research_page_html): """Test that form groups have proper fieldset and legend""" soup = research_page_html # Check if there are any fieldsets fieldsets = soup.find_all("fieldset") # Modern forms might not use fieldsets if len(fieldsets) == 0: # Check for alternative grouping methods form_groups = soup.find_all( class_=re.compile(r"form-group|field-group|input-group") ) assert len(form_groups) > 0 or len(soup.find_all("form")) > 0, ( "Should have some form of input grouping (fieldset, form-group, or form)" ) else: # If fieldsets exist, they should have legends for fieldset in fieldsets: legend = fieldset.find("legend") if legend is None: # Check for aria-label as alternative aria_label = fieldset.get("aria-label") or fieldset.get( "aria-labelledby" ) assert aria_label is not None, ( "Fieldset should have a legend or aria-label" ) def test_aria_attributes(self, research_page_html): """Test that ARIA attributes are properly set where needed""" soup = research_page_html # Check for any interactive elements interactive_elements = soup.find_all( ["button", "a", "input", "select", "textarea"] ) # Check that interactive elements have appropriate accessibility attributes for element in interactive_elements: element_type = element.name element_text = element.get_text(strip=True) # Buttons and links should have accessible text if element_type in ["button", "a"]: aria_label = element.get("aria-label") title = element.get("title") # Should have text content, aria-label, or title assert element_text or aria_label or title, ( f"{element_type} element should have accessible text" ) def test_keyboard_hints_present(self, research_page_html): """Test that keyboard navigation hints are present""" soup = research_page_html # Look for keyboard hint indicators - not all apps show these # Modern apps might show keyboard hints differently # Check for focusable elements at minimum focusable_elements = soup.find_all( ["a", "button", "input", "select", "textarea"] ) assert len(focusable_elements) > 0, ( "Page should have focusable elements" ) def test_form_structure(self, research_page_html): """Test that forms have proper structure""" soup = research_page_html forms = soup.find_all("form") if len(forms) == 0: # Might be a single-page app with AJAX # Check for input elements at least inputs = soup.find_all(["input", "textarea", "select"]) assert len(inputs) > 0, "Page should have some form inputs" else: for form in forms: # Check form has action or is handled by JavaScript action = form.get("action") form_id = form.get("id") form_class = form.get("class", []) # Form should have some identifier or action assert action or form_id or form_class, ( "Form should have action, id, or class" ) def test_semantic_markup(self, research_page_html): """Test that semantic HTML5 elements are used""" soup = research_page_html # Check for semantic elements semantic_elements = [ "header", "nav", "main", "footer", "section", "article", ] found_semantic = [] for element in semantic_elements: if soup.find(element): found_semantic.append(element) # Should use at least some semantic elements assert len(found_semantic) > 0, ( f"Should use semantic HTML5 elements. Found: {found_semantic}" ) def test_required_fields_marked(self, research_page_html): """Test that required fields are properly marked""" soup = research_page_html # Find all required inputs required_inputs = soup.find_all(attrs={"required": True}) for input_elem in required_inputs: input_id = input_elem.get("id") # Check for visual indicator if input_id: label = soup.find("label", {"for": input_id}) if label: label_text = label.get_text() # Should have some indicator like *, (required), etc. has_indicator = ( "*" in label_text or "required" in label_text.lower() or label.find(class_=re.compile(r"required")) ) # Also check for aria-required aria_required = input_elem.get("aria-required") == "true" assert has_indicator or aria_required, ( f"Required field '{input_id}' should have visual indicator or aria-required" ) def test_error_handling_structure(self, research_page_html): """Test that error messages have proper structure""" soup = research_page_html # Look for error message containers error_containers = soup.find_all( class_=re.compile(r"error|alert|message|flash") ) # Page should have some way to display errors assert len(error_containers) > 0 or soup.find( attrs={"role": "alert"} ), ( "Should have containers for error messages (class containing 'error', 'alert', or role='alert')" ) class TestAccessibilityConfiguration: """Test accessibility-related configuration and styling""" def test_css_focus_styles(self, authenticated_client): """Test that CSS includes focus styles""" # Try to get main CSS file response = authenticated_client.get("/static/css/style.css") if response.status_code == 200: css_content = response.data.decode("utf-8") # Check for focus styles has_focus_styles = ( ":focus" in css_content or "focus-visible" in css_content or "outline" in css_content ) assert has_focus_styles, ( "CSS should include focus styles for keyboard navigation" ) else: # CSS might be bundled or in different location pytest.skip("Could not access main CSS file") def test_responsive_meta_tag(self, authenticated_client): """Test that pages have responsive viewport meta tag""" response = authenticated_client.get("/") assert response.status_code == 200 soup = BeautifulSoup(response.data, "html.parser") viewport_meta = soup.find("meta", {"name": "viewport"}) assert viewport_meta is not None, "Should have viewport meta tag" content = viewport_meta.get("content", "") assert "width=device-width" in content, "Viewport should be responsive" def test_lang_attribute(self, authenticated_client): """Test that HTML has lang attribute""" response = authenticated_client.get("/") assert response.status_code == 200 soup = BeautifulSoup(response.data, "html.parser") html_tag = soup.find("html") assert html_tag is not None, "Should have html tag" lang = html_tag.get("lang") assert lang is not None, "HTML tag should have lang attribute" assert lang in ["en", "en-US", "en-GB"], ( f"Lang should be English variant, got: {lang}" ) def test_skip_navigation_link(self, authenticated_client): """Test for skip navigation link""" response = authenticated_client.get("/") assert response.status_code == 200 soup = BeautifulSoup(response.data, "html.parser") # Look for skip link skip_link = soup.find("a", text=re.compile(r"skip|main content", re.I)) # Skip link is a best practice but not always present if skip_link: href = skip_link.get("href") assert href and href.startswith("#"), ( "Skip link should be an anchor link" ) def test_heading_hierarchy(self, authenticated_client): """Test that headings follow proper hierarchy""" response = authenticated_client.get("/") assert response.status_code == 200 soup = BeautifulSoup(response.data, "html.parser") # Find all headings headings = soup.find_all(["h1", "h2", "h3", "h4", "h5", "h6"]) if len(headings) > 0: # Should have at least one h1 h1_tags = soup.find_all("h1") assert len(h1_tags) >= 1, "Page should have at least one h1 tag" # Check heading order doesn't skip levels heading_levels = [int(h.name[1]) for h in headings] for i in range(1, len(heading_levels)): level_diff = heading_levels[i] - heading_levels[i - 1] assert level_diff <= 1, ( f"Heading hierarchy should not skip levels. " f"Found h{heading_levels[i - 1]} followed by h{heading_levels[i]}" )