from html.parser import HTMLParser from pathlib import Path from app.auth.services import create_user from app.tasks.services import configure_focus, create_task class PageAuditParser(HTMLParser): def __init__(self) -> None: super().__init__() self.ids: list[str] = [] self.labels: set[str] = set() self.controls: list[tuple[str, str | None, str | None, str | None]] = [] self.button_stack: list[list[str]] = [] self.button_names: list[str] = [] self.lang: str | None = None self.title_parts: list[str] = [] self.in_title = False self.has_main = False self.has_skip_link = False self.h1_count = 0 def handle_starttag(self, tag: str, attrs) -> None: attributes = dict(attrs) if element_id := attributes.get("id"): self.ids.append(element_id) if tag == "html": self.lang = attributes.get("lang") elif tag == "title": self.in_title = True elif tag == "main" and attributes.get("id") == "main-content": self.has_main = True elif tag == "a" and attributes.get("href") == "#main-content": self.has_skip_link = True elif tag == "h1": self.h1_count += 1 elif tag == "label" and attributes.get("for"): self.labels.add(attributes["for"]) elif tag in {"input", "select", "textarea"}: self.controls.append( ( tag, attributes.get("id"), attributes.get("type"), attributes.get("aria-label"), ) ) elif tag == "button": self.button_stack.append([attributes.get("aria-label", "")]) def handle_endtag(self, tag: str) -> None: if tag == "title": self.in_title = False elif tag == "button" and self.button_stack: parts = self.button_stack.pop() self.button_names.append(" ".join(parts).strip()) def handle_data(self, data: str) -> None: if self.in_title: self.title_parts.append(data) if self.button_stack: self.button_stack[-1].append(data) def login(client, email: str) -> None: client.post( "/auth/login", data={"email": email, "password": "secure password"}, ) def test_primary_pages_have_structural_accessibility(client, db): user = create_user("audit_user", "audit@example.com", "secure password") task = create_task( user.id, title="Audited task", description="Readable details", status="not_started", priority="important", due_date=None, ) configure_focus( task, timer_minutes=5, context="Quiet desk", chunk_minutes=[5, 10], ) login(client, user.email) pages = ( "/", "/tasks/", "/tasks/today", f"/tasks/{task.id}", f"/tasks/{task.id}/focus", "/tasks/new", "/tasks/transfer", "/settings/", ) for path in pages: response = client.get(path) parser = PageAuditParser() parser.feed(response.get_data(as_text=True)) assert response.status_code == 200, path assert parser.lang == "en", path assert "".join(parser.title_parts).strip(), path assert parser.has_main, path assert parser.has_skip_link, path assert parser.h1_count >= 1, path assert len(parser.ids) == len(set(parser.ids)), path assert all(parser.button_names), path for _tag, element_id, input_type, aria_label in parser.controls: if input_type in {"hidden", "submit"}: continue assert element_id, (path, input_type) assert element_id in parser.labels or aria_label, (path, element_id) def test_css_contains_reflow_focus_target_and_motion_contracts(): css = Path("app/static/css/style.css").read_text(encoding="utf-8") assert "width: min(100% - 2rem, 62rem)" in css assert "@media (max-width: 38rem)" in css assert "grid-template-columns: 1fr" in css assert "min-height: 2.75rem" in css assert "outline: 3px solid var(--focus)" in css assert "@media (prefers-reduced-motion: reduce)" in css reduced_motion = css.split("@media (prefers-reduced-motion: reduce)", 1)[1] assert "transition-duration: 0.01ms !important" in reduced_motion assert "animation: none" in reduced_motion assert "@media (forced-colors: active)" in css def relative_luminance(hex_color: str) -> float: channels = [int(hex_color[index : index + 2], 16) / 255 for index in (1, 3, 5)] linear = [ value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 for value in channels ] return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] def contrast_ratio(first: str, second: str) -> float: light, dark = sorted( (relative_luminance(first), relative_luminance(second)), reverse=True, ) return (light + 0.05) / (dark + 0.05) def test_primary_color_tokens_meet_contrast_contract(): css = Path("app/static/css/style.css").read_text(encoding="utf-8").lower() text_pairs = ( ("#2d2d3a", "#f5f0e8"), ("#2d2d3a", "#fffdf9"), ("#626274", "#fffdf9"), ("#57436c", "#fffdf9"), ("#f5f0e8", "#1e1e24"), ("#c5bdcc", "#292932"), ("#d9c5f1", "#292932"), ) for foreground, background in text_pairs: assert foreground in css assert background in css assert contrast_ratio(foreground, background) >= 4.5 assert contrast_ratio("#67547d", "#fffdf9") >= 3 assert contrast_ratio("#d4c0ee", "#292932") >= 3