flask_template_codex/tests/polish/test_accessibility.py
2026-08-08 05:37:41 +02:00

173 lines
5.7 KiB
Python

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