intent/tests/a11y.py
Alec Perkins b9378b8397 Add projects and features with revision history (Phase 2)
Projects under organizations with fixed URL name and feature ID
prefix, three visibility levels, open contribution, per-project
roles, settings and typed-confirmation delete. Hidden projects 404.
Leaving an org drops its project roles. Project access rules live in
intent/access.py alongside org access.

Features numbered per project (INT-12) in a tree. Every change is a
revision with a required note; history, old revisions, and
side-by-side field and line diffs between any two. Cycles and
superseded-without-replacement are rejected. Markdown renders safely
with source-line attributes for Phase 3 line comments and headings
shifted below the page h1. Server-side preview works without JS.

Structural accessibility check runs on every page in tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:58:16 -04:00

67 lines
2.5 KiB
Python

"""Structural accessibility checks for rendered pages.
Not a substitute for axe or a screen reader, but cheap enough to run on every page in unit tests.
"""
from html.parser import HTMLParser
_UNLABELLED_INPUT_TYPES = {"hidden", "submit", "button", "reset", "image"}
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.html_lang = None
self.headings = []
self.ids = []
self.label_fors = set()
self.controls = []
self.images_without_alt = 0
self._label_depth = 0
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == "html":
self.html_lang = attrs.get("lang")
if "id" in attrs:
self.ids.append(attrs["id"])
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
self.headings.append(int(tag[1]))
if tag == "label":
self._label_depth += 1
if attrs.get("for"):
self.label_fors.add(attrs["for"])
if tag == "img" and "alt" not in attrs:
self.images_without_alt += 1
if tag in {"input", "select", "textarea"}:
if tag == "input" and attrs.get("type", "text") in _UNLABELLED_INPUT_TYPES:
return
wrapped = self._label_depth > 0
named = bool(attrs.get("aria-label") or attrs.get("aria-labelledby"))
self.controls.append((attrs.get("id"), attrs.get("name"), wrapped or named))
def handle_endtag(self, tag):
if tag == "label":
self._label_depth -= 1
def assert_accessible_html(html: str) -> None:
page = _Collector()
page.feed(html)
problems = []
if not page.html_lang:
problems.append("<html> has no lang")
if page.headings.count(1) != 1:
problems.append(f"expected one <h1>, found {page.headings.count(1)}")
for before, after in zip(page.headings, page.headings[1:], strict=False):
if after > before + 1:
problems.append(f"heading level skips from h{before} to h{after}")
duplicates = {i for i in page.ids if page.ids.count(i) > 1}
if duplicates:
problems.append(f"duplicate ids: {sorted(duplicates)}")
for control_id, name, labelled in page.controls:
if not (labelled or control_id in page.label_fors):
problems.append(f"form control without a label: id={control_id} name={name}")
if page.images_without_alt:
problems.append(f"{page.images_without_alt} <img> without alt")
assert not problems, "\n".join(problems)