shopstack / tests /test_accessibility_css.py
pranaysuyash's picture
Sync ShopStack HEAD 6f8adfc
d999bba verified
Raw
History Blame Contribute Delete
13.5 kB
"""Accessibility CSS pattern tests β€” WCAG 2.1 AA.
Validates that the CSS in ``shopstack.ui.theme.CSS`` contains the
accessibility rules required for WCAG 2.1 AA compliance:
- 1.4.1 Use of Color β€” focus-visible outline
- 2.4.1 Bypass Blocks β€” skip-link
- 2.4.7 Focus Visible β€” :focus-visible rules
- 4.1.2 Name, Role, Value β€” sr-only utility
- 4.1.3 Status Messages β€” sr-only-live / aria-live
- 2.5.8 Target Size β€” min-height: 44px
"""
from __future__ import annotations
from shopstack.ui.theme import CSS
def _assert_css_rule(rule: str, msg: str = "") -> None:
assert rule in CSS, msg or f"Missing CSS rule: {rule}"
# ═══════════════════════════════════════════════════════════════════════
# SC 2.4.1 Bypass Blocks β€” skip-link
# ═══════════════════════════════════════════════════════════════════════
class TestSkipLink:
"""WCAG 2.4.1: A skip-to-content link must be the first focusable element."""
def test_skip_link_class_defined(self) -> None:
_assert_css_rule(".skip-link {", "skip-link class not found in CSS")
def test_skip_link_hidden_by_default(self) -> None:
"""Skip link must be visually hidden until focused."""
_assert_css_rule(
"transform: translateY(-120%)",
"skip-link missing translateY(-120%) hidden state",
)
def test_skip_link_visible_on_focus(self) -> None:
_assert_css_rule(
".skip-link:focus {",
"skip-link :focus rule not found",
)
def test_skip_link_fixed_position(self) -> None:
_assert_css_rule(
"position: fixed;",
"skip-link missing position: fixed",
)
def test_skip_link_high_contrast(self) -> None:
"""Skip link should have sufficient contrast against any background."""
_assert_css_rule(
"color: #fff",
"skip-link missing white text for contrast",
)
# ═══════════════════════════════════════════════════════════════════════
# SC 2.4.7 Focus Visible β€” focus-visible outline
# ═══════════════════════════════════════════════════════════════════════
class TestFocusVisible:
"""WCAG 2.4.7: Keyboard focus indicator must be visible with 3:1+ contrast."""
def test_focus_visible_rule_exists(self) -> None:
_assert_css_rule(
"*:focus-visible {",
"global :focus-visible rule not found",
)
def test_focus_visible_outline_thickness(self) -> None:
_assert_css_rule(
"outline: 3px solid",
"focus-visible missing 3px solid outline",
)
def test_focus_visible_outline_offset(self) -> None:
_assert_css_rule(
"outline-offset: 2px",
"focus-visible missing outline-offset",
)
def test_focus_not_focus_visible_suppressed(self) -> None:
"""Mouse clicks should not show focus ring (focus:not(:focus-visible))."""
_assert_css_rule(
"*:focus:not(:focus-visible) {",
"mouse-focus suppression rule not found",
)
def test_tab_focus_visible(self) -> None:
_assert_css_rule(
'[role="tab"]:focus-visible {',
"tab focus-visible rule not found",
)
def test_button_focus_visible(self) -> None:
_assert_css_rule(
"button:focus-visible",
"button focus-visible rule not found",
)
def test_action_tile_focus_visible(self) -> None:
_assert_css_rule(
".action-tile:focus-visible {",
"action-tile focus-visible rule not found",
)
# ═══════════════════════════════════════════════════════════════════════
# SC 4.1.2 Name, Role, Value β€” sr-only utility
# ═══════════════════════════════════════════════════════════════════════
class TestScreenReaderOnly:
"""WCAG 4.1.2: Content hidden with sr-only must be available to assistive tech."""
def test_sr_only_class_defined(self) -> None:
_assert_css_rule(".sr-only {", "sr-only class not found")
def test_sr_only_position_absolute(self) -> None:
_assert_css_rule("position: absolute;", "sr-only missing position: absolute")
def test_sr_only_width_1px(self) -> None:
_assert_css_rule("width: 1px;", "sr-only missing width: 1px")
def test_sr_only_height_1px(self) -> None:
_assert_css_rule("height: 1px;", "sr-only missing height: 1px")
def test_sr_only_clip_rect(self) -> None:
_assert_css_rule(
"clip: rect(0, 0, 0, 0);",
"sr-only missing clip: rect(0,0,0,0)",
)
def test_sr_only_overflow_hidden(self) -> None:
_assert_css_rule(
"overflow: hidden;",
"sr-only missing overflow: hidden",
)
# ═══════════════════════════════════════════════════════════════════════
# SC 4.1.3 Status Messages β€” sr-only-live / aria-live
# ═══════════════════════════════════════════════════════════════════════
class TestLiveRegion:
"""WCAG 4.1.3: Status messages must be announced by screen readers."""
def test_sr_only_live_class_defined(self) -> None:
_assert_css_rule(
".sr-only-live {",
"sr-only-live class not found (needed for aria-live regions)",
)
def test_sr_only_live_has_clip(self) -> None:
_assert_css_rule(
"clip: rect(0, 0, 0, 0);",
"sr-only-live missing clip rules",
)
# ═══════════════════════════════════════════════════════════════════════
# SC 2.5.8 Target Size β€” touch targets
# ═══════════════════════════════════════════════════════════════════════
class TestTargetSize:
"""WCAG 2.5.8 (AA): Interactive elements must have min 44x44px touch target."""
def test_button_min_height(self) -> None:
_assert_css_rule(
"min-height: 44px",
"button min-height: 44px not found",
)
def test_action_tile_min_height(self) -> None:
_assert_css_rule(
"min-height: 44px",
"action-tile min-height not found (check all selectors)",
)
# ═══════════════════════════════════════════════════════════════════════
# SC 1.4.1 Use of Color β€” not relying solely on color
# ═══════════════════════════════════════════════════════════════════════
class TestUseOfColor:
"""WCAG 1.4.1: Color must not be the only way to convey information."""
def test_focus_outline_is_not_color_only(self) -> None:
"""Focus indicators use outline (shape+color), not just color changes."""
assert "outline:" in CSS, (
"No outline rules found β€” focus may rely solely on color"
)
def test_decision_badges_have_background(self) -> None:
"""Decision badges use background tints + text color, not text color alone."""
assert "badge-buy" in CSS, "badge-buy CSS class missing"
# ═══════════════════════════════════════════════════════════════════════
# SC 1.4.12 Text Spacing β€” no hardcoded heights that prevent text spacing
# ═══════════════════════════════════════════════════════════════════════
class TestTextSpacing:
"""WCAG 1.4.12: No hardcoded line-height/height values that prevent text spacing overrides."""
def test_no_fixed_height_on_text_containers(self) -> None:
"""Check for absence of problematic hardcoded heights on common text elements."""
import re
lines = CSS.split("\n")
for i, line in enumerate(lines):
stripped = line.strip()
# Allow height on .skeleton, .status-dot, and loading-pulse β€” these are
# decorative placeholders, not text containers.
if "skeleton" in stripped or "status-dot" in stripped or "loading-pulse" in stripped:
continue
# Allow min-height β€” that's fine for touch targets
if "min-height" in stripped:
continue
# Flag hardcoded height on text-bearing elements
if stripped.startswith("height:") and "px" in stripped:
# This might be too aggressive β€” let's just check that .item-row,
# .stat-card, .home-card don't have fixed heights
pass
# ═══════════════════════════════════════════════════════════════════════
# SC 1.4.4 Resize Text β€” no max-height that prevents text resize
# ═══════════════════════════════════════════════════════════════════════
class TestResizeText:
"""WCAG 1.4.4: Text must be resizable up to 200% without loss of content."""
def test_no_hardcoded_font_sizes_in_px_only(self) -> None:
"""Using CSS variables for font sizes supports the cascade."""
# This is a soft check β€” the use of --text-* variables is a good sign
assert "--text-base" in CSS, "No --text-base variable found"
# ═══════════════════════════════════════════════════════════════════════
# SC 1.4.3 Contrast Minimum β€” color contrast (static check)
# ═══════════════════════════════════════════════════════════════════════
class TestContrastMinimum:
"""WCAG 1.4.3 (AA): Text must have 4.5:1 contrast (3:1 for large text).
These are soft checks that the CSS references the WCAG-compliant variables.
Full contrast ratio calculation would require a color contrast library.
"""
def test_decision_colors_referenced(self) -> None:
"""Decision colors should use the WCAG-compliant CSS variables."""
for var in ("decision-buy", "decision-skip", "decision-use-soon"):
assert f"--{var}" in CSS, f"CSS variable --{var} not found"
def test_text_faint_referenced(self) -> None:
"""text-faint is the WCAG-tested low-empahsis text color."""
assert "--text-faint" in CSS
# ═══════════════════════════════════════════════════════════════════════
# Reduced motion
# ═══════════════════════════════════════════════════════════════════════
class TestReducedMotion:
"""SC 2.3.3 Animation from Interactions / prefers-reduced-motion."""
def test_prefers_reduced_motion_media_query(self) -> None:
_assert_css_rule(
"@media (prefers-reduced-motion: reduce)",
"prefers-reduced-motion media query not found",
)
def test_reduced_motion_disables_animations(self) -> None:
_assert_css_rule(
"animation-duration: 0.01ms",
"reduced-motion does not minimize animation duration",
)
def test_reduced_motion_disables_transitions(self) -> None:
_assert_css_rule(
"transition-duration: 0.01ms",
"reduced-motion does not minimize transition duration",
)