"""Accessibility (a11y) tests — WCAG 2.1 AA static HTML analysis.
Every HTML-generating component function in ``shopstack.ui`` is parsed with
BeautifulSoup and validated for correct ARIA attributes, roles, labels, and
semantic structure. No browser/DOM required — unit-testing the static HTML.
Coverage:
- shopstack.ui.components.primitives (item_row, stat_card, data_table, ...)
- shopstack.ui.components.cards (card, render_decision_card, ...)
- shopstack.ui.renderers.image_cards (SVG cards)
- shopstack.ui.renderers.decision_cards (market basket, decision panel, ...)
"""
from __future__ import annotations
from bs4 import BeautifulSoup
from shopstack.ui.components.primitives import (
confirm_dialog,
data_table,
empty_state_enhanced,
item_row,
loading_skeleton,
stat_card,
toast,
)
from shopstack.ui.components.cards import (
badge_html,
card,
empty_state,
render_action_grid,
render_action_tile,
render_grouped_cards,
render_decision_card as render_decision_card_html,
render_hero_panel,
render_metric,
render_rows,
render_workflow_rail,
)
from shopstack.ui.renderers import (
decision_cards as dc,
image_cards as ic,
shopping_results as sr,
)
from shopstack.services.results import (
CompletionItem,
MarkPurchasedResult,
PurchaseResultItem,
ShoppingCompletionResult,
)
from shopstack.schemas.models import DecisionSet, DecisionResult
from shopstack.ui.screens._utils import (
render_home_advice,
render_list_summary,
render_low_stock,
render_recent_purchases,
)
# ═══════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════
def _parse(html: str) -> BeautifulSoup:
"""Parse HTML string with lxml and return a BeautifulSoup tree."""
return BeautifulSoup(html, "lxml")
def _assert_role(soup: BeautifulSoup, role: str, msg: str = "") -> None:
"""Assert that at least one element has the given role."""
assert soup.find(attrs={"role": role}) is not None, (
msg or f"Expected role='{role}' not found in HTML"
)
# ═══════════════════════════════════════════════════════════════════════
# shopstack.ui.components.primitives
# ═══════════════════════════════════════════════════════════════════════
class TestItemRow:
"""WCAG: role='group', aria-label includes name+quantity."""
def test_role_group_present(self):
html = item_row("Milk", 2.0, "L")
soup = _parse(html)
_assert_role(soup, "group", "item_row missing role='group'")
def test_aria_label_contains_name_and_quantity(self):
html = item_row("Milk", 2.0, "L")
soup = _parse(html)
el = soup.find(attrs={"role": "group"})
assert el is not None
label = el.get("aria-label", "")
assert "Milk" in label
assert "2.0" in label or "2 L" in label
def test_aria_label_with_decision(self):
html = item_row("Eggs", 12, "pieces", decision="buy")
soup = _parse(html)
el = soup.find(attrs={"role": "group"})
assert el is not None
assert "Eggs" in el.get("aria-label", "")
def test_escaped_content_does_not_break_html(self):
"""html.escape is applied; HTML injection should be escaped."""
html = item_row('', 1, "unit")
assert "<script>" in html
assert "', "green")
assert "<script>" in html
assert ""
status = "active"
class MockList:
items = [MockItem()]
html = render_list_summary(MockList())
assert "<script>" in html
assert ""
total_price = 0.0
html = render_recent_purchases([MockPurchase()])
assert "<script>" in html
assert "