173 lines
5.7 KiB
Python
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
|
|
|