{_e(f['title'])}
+ {_e(f['page'])} +- {bullets}
diff --git a/a11y_report_generator.py b/a11y_report_generator.py new file mode 100644 index 0000000..8858a9e --- /dev/null +++ b/a11y_report_generator.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python +""" +Build ACCESSIBILITY_REPORT.md (and .html) from the findings written by the +accessibility suite into reports/a11y/. + +Run automatically by conftest.pytest_sessionfinish after an accessibility run, +or by hand: + + python a11y_report_generator.py + +The report is organised around the two checklists the suite is written against: + - WCAG 2.1 success criteria https://www.w3.org/TR/WCAG21/ + - The A11Y Project checklist https://www.a11yproject.com/checklist/ +""" + +import html +import json +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent +FINDINGS_DIR = REPO_ROOT / "reports" / "a11y" +MD_OUT = REPO_ROOT / "ACCESSIBILITY_REPORT.md" +HTML_OUT = REPO_ROOT / "ACCESSIBILITY_REPORT.html" + +STATUS_ICON = { + "pass": "โ ", + "fail": "โ", + "not_implemented": "๐ง", + "skipped": "โญ๏ธ", +} + + +def load_findings() -> list: + if not FINDINGS_DIR.exists(): + return [] + out = [] + for f in sorted(FINDINGS_DIR.glob("*.json")): + try: + out.append(json.loads(f.read_text(encoding="utf-8"))) + except Exception as e: # a truncated file shouldn't kill the report + print(f"โ ๏ธ skipping unreadable finding {f.name}: {e}") + return out + + +def build_markdown(findings: list) -> str: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + by_status = defaultdict(list) + for f in findings: + by_status[f["status"]].append(f) + + passed = len(by_status["pass"]) + failed = len(by_status["fail"]) + pending = len(by_status["not_implemented"]) + total = len(findings) + + L = [] + L.append("# CivicDataSpace โ Accessibility Report") + L.append("") + L.append(f"_Generated {now}_") + L.append("") + L.append("Standards this suite is written against:") + L.append("") + L.append("- [WCAG 2.1](https://www.w3.org/TR/WCAG21/) โ Level A and AA") + L.append("- [The A11Y Project checklist](https://www.a11yproject.com/checklist/)") + L.append("- Accessibility Options widget specification (13 controls)") + L.append("") + + # โโ Summary โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + L.append("## Summary") + L.append("") + L.append("| Result | Count |") + L.append("|---|---:|") + L.append(f"| โ Passed | {passed} |") + L.append(f"| โ Failed | {failed} |") + L.append(f"| ๐ง Not implemented | {pending} |") + L.append(f"| **Total checks** | **{total}** |") + L.append("") + + if total == 0: + L.append("> No findings recorded. Run the suite first:") + L.append("> `pytest tests/accessibility -m accessibility`") + L.append("") + return "\n".join(L) + + if failed: + L.append(f"**{failed} check(s) failed.** See _Failures_ below.") + else: + L.append("**No conformance failures recorded.**") + L.append("") + + # โโ Failures โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + if by_status["fail"]: + L.append("## Failures") + L.append("") + for f in sorted(by_status["fail"], key=lambda x: (x["check"], x["page"])): + L.append(f"### โ {f['title']} โ `{f['page']}`") + L.append("") + L.append(f"- **WCAG:** {', '.join(f['wcag'])}") + L.append(f"- **A11Y Project:** {f['a11y_project']}") + detail = f.get("detail") or {} + if detail.get("url"): + L.append(f"- **Page:** `{detail['url']}`") + for p in detail.get("problems", []) or []: + L.append(f"- โ ๏ธ {p}") + if f.get("violations"): + L.append("") + L.append("| Rule | Impact | Nodes | WCAG tags | Help |") + L.append("|---|---|---:|---|---|") + for v in f["violations"]: + tags = ", ".join(v.get("tags", [])) or "โ" + L.append( + f"| `{v['id']}` | {v.get('impact') or 'โ'} | {v.get('node_count', 0)} " + f"| {tags} | [{v.get('help','')}]({v.get('helpUrl','')}) |" + ) + L.append("") + + # โโ Outstanding / not implemented โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + if by_status["not_implemented"]: + L.append("## Outstanding โ not yet implemented") + L.append("") + L.append( + "These are **not test errors**. The functionality does not exist on the " + "deployment under test, so the checks are recorded as pending rather than " + "passed. They become live checks as soon as the feature ships." + ) + L.append("") + note = next( + (f["detail"].get("note") for f in by_status["not_implemented"] + if (f.get("detail") or {}).get("note")), + None, + ) + if note: + L.append(f"> {note}") + L.append("") + L.append("| Check | Item | WCAG |") + L.append("|---|---|---|") + for f in sorted(by_status["not_implemented"], key=lambda x: x["page"]): + detail = f.get("detail") or {} + item = detail.get("control") or f["page"] + L.append(f"| {f['title']} | {item} | {', '.join(f['wcag'])} |") + L.append("") + + # โโ Coverage by WCAG criterion โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + L.append("## Coverage by WCAG 2.1 success criterion") + L.append("") + by_sc = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0, "skipped": 0}) + for f in findings: + for sc in f["wcag"]: + by_sc[sc][f["status"]] = by_sc[sc].get(f["status"], 0) + 1 + + L.append("| Success criterion | โ | โ | ๐ง |") + L.append("|---|---:|---:|---:|") + for sc in sorted(by_sc): + c = by_sc[sc] + L.append(f"| {sc} | {c['pass']} | {c['fail']} | {c['not_implemented']} |") + L.append("") + + # โโ Coverage by A11Y Project item โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + L.append("## Coverage by A11Y Project checklist item") + L.append("") + by_item = defaultdict(lambda: {"pass": 0, "fail": 0, "not_implemented": 0, "skipped": 0}) + for f in findings: + by_item[f["a11y_project"]][f["status"]] += 1 + + L.append("| Checklist item | โ | โ | ๐ง |") + L.append("|---|---:|---:|---:|") + for item in sorted(by_item): + c = by_item[item] + L.append(f"| {item} | {c['pass']} | {c['fail']} | {c['not_implemented']} |") + L.append("") + + # โโ Per-page matrix โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + L.append("## Results by page") + L.append("") + pages = sorted({f["page"] for f in findings}) + checks = sorted({f["check"] for f in findings}) + matrix = {(f["page"], f["check"]): f["status"] for f in findings} + + L.append("| Page | " + " | ".join(checks) + " |") + L.append("|---" * (len(checks) + 1) + "|") + for page in pages: + row = [page] + for check in checks: + st = matrix.get((page, check)) + row.append(STATUS_ICON.get(st, "ยท") if st else "ยท") + L.append("| " + " | ".join(row) + " |") + L.append("") + L.append("Legend: โ pass ยท โ fail ยท ๐ง not implemented ยท ยท not applicable to this page") + L.append("") + + # โโ Passed detail โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ + L.append("## Passed checks") + L.append("") + L.append("| Check | Page | WCAG |") + L.append("|---|---|---|") + for f in sorted(by_status["pass"], key=lambda x: (x["check"], x["page"])): + L.append(f"| {f['title']} | `{f['page']}` | {', '.join(f['wcag'])} |") + L.append("") + + return "\n".join(L) + + +STATUS_LABEL = { + "pass": "Pass", + "fail": "Fail", + "not_implemented": "Not implemented", + "skipped": "Skipped", +} + +# axe impact -> badge class, for the per-violation rows inside a failure card. +IMPACT_CLASS = { + "critical": "sev-critical", + "serious": "sev-serious", + "moderate": "sev-moderate", + "minor": "sev-minor", +} + + +def _e(s) -> str: + """HTML-escape, tolerating None.""" + return html.escape(str(s)) if s is not None else "" + + +def _bar(passed: int, failed: int, pending: int) -> str: + """Inline three-segment progress bar (pass/fail/pending) for coverage rows.""" + total = passed + failed + pending + if total == 0: + return '
' + seg = lambda n, cls: f'' if n else "" + return ( + '" + ) + + +def _violations_table(violations: list) -> str: + if not violations: + return "" + rows = [] + for v in violations: + impact = (v.get("impact") or "").lower() + badge_cls = IMPACT_CLASS.get(impact, "sev-minor") + tags = ", ".join(v.get("tags", [])) or "โ" + samples = v.get("sample_targets") or [] + sample_html = ( + "{_e(s)}' for s in samples[:3])
+ if samples else ""
+ )
+ help_url = v.get("helpUrl") or "#"
+ rows.append(f"""
+ {_e(v.get('id'))}| Rule | Impact | Nodes | WCAG tags | Help |
|---|
No findings recorded yet.
+Run the suite first: pytest tests/accessibility -m accessibility
These are not test errors. The functionality does not + exist on the deployment under test, so the checks are recorded as pending rather + than passed. They become live checks as soon as the feature ships.
+ {f'{_e(note)}' if note else ''} +
| Check | Item | WCAG |
|---|
{_e(f['page'])}{ + f'{failed} check(s) failed. See Failures below.' + if failed else 'No conformance failures recorded.' + }
+| Success criterion | โ | โ | โฆ |
|---|
| Checklist item | โ | โ | โฆ |
|---|
| Page | {header_cells}
|---|
+ โ pass + โ fail + โฆ not implemented + ยท not applicable +
+| Check | Page | WCAG |
|---|
Generated {_e(generated_at)}
++ WCAG 2.1 (A/AA) + ยท + The A11Y Project checklist + ยท + Accessibility Options widget spec (13 controls) +
+