reading-list / tests /test_ui.py
pardeep-singh's picture
Deploy polished warm-library UI from main b09a3f3
eab7052 verified
Raw History Blame Contribute Delete
15.5 kB
import json
import re
import shutil
import subprocess
from html.parser import HTMLParser
import pytest
VOID_TAGS = {"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "wbr"}
class _Collector(HTMLParser):
"""Collects every start tag as (tag, attrs-dict), with the ids of its ancestors,
plus <option> values/texts per <select> and <label> texts keyed by their `for`."""
def __init__(self):
super().__init__()
self.tags: list[tuple[str, dict[str, str | None]]] = []
self.options: dict[str, list[str]] = {}
self.option_texts: dict[str, list[str]] = {}
self.label_texts: dict[str, str] = {}
self.legend_texts: dict[str, str] = {}
self._select_id: str | None = None
self._stack: list[tuple[str, str | None]] = []
self._text_target: list[str] | None = None
self._label_for: str | None = None
self._legend_of: str | None = None
def _record(self, tag, attrs):
attrs = dict(attrs)
attrs["__ancestors__"] = [i for _, i in self._stack if i]
self.tags.append((tag, attrs))
return attrs
def handle_starttag(self, tag, attrs):
attrs = self._record(tag, attrs)
if tag == "select":
self._select_id = attrs.get("id")
self.options[self._select_id] = []
self.option_texts[self._select_id] = []
elif tag == "option" and self._select_id is not None:
self.options[self._select_id].append(attrs.get("value"))
self.option_texts[self._select_id].append("")
elif tag == "label" and attrs.get("for"):
self._label_for = attrs["for"]
self.label_texts[self._label_for] = ""
elif tag == "legend" and self._stack:
self._legend_of = self._stack[-1][1]
self.legend_texts[self._legend_of] = ""
if tag not in VOID_TAGS:
self._stack.append((tag, attrs.get("id")))
def handle_startendtag(self, tag, attrs):
self._record(tag, attrs)
def handle_endtag(self, tag):
if tag == "select":
self._select_id = None
elif tag == "label":
self._label_for = None
elif tag == "legend":
self._legend_of = None
for i in range(len(self._stack) - 1, -1, -1):
if self._stack[i][0] == tag:
del self._stack[i:]
break
def handle_data(self, data):
if self._label_for is not None:
self.label_texts[self._label_for] += data
elif self._legend_of is not None:
self.legend_texts[self._legend_of] += data
elif self._select_id is not None and self._stack and self._stack[-1][0] == "option":
self.option_texts[self._select_id][-1] += data
def find(self, tag, **attrs):
return [a for t, a in self.tags if t == tag and all(a.get(k) == v for k, v in attrs.items())]
def find_any(self, **attrs):
return [a for _, a in self.tags if all(a.get(k) == v for k, v in attrs.items())]
def _label_text(dom, control_id):
return " ".join(dom.label_texts.get(control_id, "").split())
@pytest.fixture
def page(client):
return client.get("/").text
@pytest.fixture
def dom(page):
parser = _Collector()
parser.feed(page)
return parser
def test_root_serves_html_page(client):
response = client.get("/")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")
def test_root_does_not_depend_on_working_directory(client, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
response = client.get("/")
assert response.status_code == 200
assert "<form" in response.text
def test_add_form_has_title_author_and_status(dom):
assert dom.find("form", id="add-form")
title = dom.find("input", id="title")
assert title and "required" in title[0]
assert dom.find("input", id="author")
assert dom.find_any(id="status")
assert [a.get("value") for a in _status_radios(dom)] == ["to-read", "reading", "done"]
def _status_radios(dom):
return [a for a in dom.find("input", type="radio") if "status" in a["__ancestors__"]]
def test_add_form_status_is_a_labelled_group_of_three_radio_chips(dom):
radios = _status_radios(dom)
assert [a.get("value") for a in radios] == ["to-read", "reading", "done"]
assert {a.get("name") for a in radios} == {"status"}
assert [a.get("value") for a in radios if "checked" in a] == ["to-read"]
assert [_label_text(dom, a.get("id")) for a in radios] == ["To read", "Reading", "Done"]
group = dom.find_any(id="status")[0]
labelledby = group.get("aria-labelledby")
if labelledby:
name = " ".join(dom.label_texts.get(labelledby, "").split()) or labelledby
assert dom.find_any(id=labelledby)
else:
name = " ".join(dom.legend_texts.get("status", "").split())
assert name == "Status"
def test_add_form_has_no_select(dom):
assert not [a for a in dom.find("select") if "add-form" in a["__ancestors__"]]
def test_submit_reads_the_checked_status_radio(page):
assert re.search(r"""input\[name=["']?status["']?\]:checked""", page)
def test_filter_is_a_group_of_exactly_four_radios_with_wire_values(dom):
assert dom.find_any(id="filter")
radios = [a for a in dom.find("input", type="radio") if "filter" in a["__ancestors__"]]
assert [a.get("value") for a in radios] == ["", "to-read", "reading", "done"]
assert {a.get("name") for a in radios} == {"filter"}
checked = [a.get("value") for a in radios if "checked" in a]
assert checked == [""]
def test_labels_use_display_text_while_values_stay_wire_values(dom, page):
radios = _status_radios(dom)
assert [a.get("value") for a in radios] == ["to-read", "reading", "done"]
assert [_label_text(dom, a.get("id")) for a in radios] == ["To read", "Reading", "Done"]
radios = [a for a in dom.find("input", type="radio") if "filter" in a["__ancestors__"]]
texts = [_label_text(dom, a.get("id")) for a in radios]
for text, expected in zip(texts, ["All", "To read", "Reading", "Done"], strict=True):
assert text.startswith(expected), f"filter label {text!r} should start with {expected!r}"
# per-row selects are built in JS from one display-label map keyed by wire value
assert re.search(r'"to-read":\s*"To read"', page)
assert re.search(r'"reading":\s*"Reading"', page)
assert re.search(r'"done":\s*"Done"', page)
def test_book_list_and_stats_regions_exist(dom):
assert dom.find("ul", id="books")
assert dom.find_any(id="stats")
def test_progress_bar_is_labelled_image_inside_stats(dom):
bars = [a for a in dom.find_any(role="img") if "stats" in a["__ancestors__"]]
assert len(bars) == 1
assert (bars[0].get("aria-label") or "").strip()
segments = [a for _, a in dom.tags if "__ancestors__" in a and bars[0].get("id") in a["__ancestors__"]]
assert [a.get("data-status") for a in segments] == ["to-read", "reading", "done"]
def test_stats_has_a_count_tile_per_status(dom):
for status in ("to-read", "reading", "done"):
tile = dom.find_any(id=f"count-{status}")
assert tile and "stats" in tile[0]["__ancestors__"]
def test_progress_label_and_widths_are_computed_without_dividing_by_zero(page):
assert "finished" in page
assert re.search(r"total\s*(===|>)\s*0", page)
def test_feedback_region_is_polite_live_region(dom):
assert dom.find("div", id="feedback", role="status", **{"aria-live": "polite"})
def test_every_form_control_has_a_label(dom):
labelled = {a.get("for") for a in dom.find("label")}
for tag in ("input", "select"):
for attrs in dom.find(tag):
assert attrs.get("id") in labelled, f"{tag}#{attrs.get('id')} has no <label for>"
def test_page_uses_contract_endpoints(page):
assert "/api/books" in page
assert "/api/stats" in page
assert "PATCH" in page
def test_page_never_builds_html_from_strings(page):
for sink in ("innerHTML", "outerHTML", "insertAdjacentHTML", "document.write"):
assert sink not in page, f"page uses {sink}"
def test_page_is_self_contained(page):
assert not re.search(r"<script[^>]+src=", page)
assert not re.search(r"<link[^>]+stylesheet", page)
assert "http://" not in page and "https://" not in page
def test_page_has_viewport_meta_for_narrow_screens(dom):
assert dom.find("meta", name="viewport")
def test_error_text_follows_contract_ui_rule(page):
# detail list -> detail[0].msg, string -> as-is
assert "Array.isArray" in page
assert ".msg" in page
def test_empty_state_illustration_is_inline_svg_hidden_from_assistive_tech(dom):
svgs = dom.find("svg")
assert svgs, "expected an inline SVG illustration for the empty state"
assert any("empty" in a["__ancestors__"] for a in svgs)
for attrs in svgs + [a for _, a in dom.tags if "illustration" in (a.get("class") or "")]:
assert attrs.get("aria-hidden") == "true"
def test_book_spines_are_decorative_and_coloured_deterministically(page):
assert re.search(r'spine\.className\s*=\s*"spine"', page)
assert re.search(r'spine\.setAttribute\("aria-hidden",\s*"true"\)', page)
# a fixed set of 8 book-cloth colours, picked by a string hash of the title
assert len(set(re.findall(r"--spine-(\d+)\s*:", page))) == 8
assert re.search(r"function\s+hash\w*\s*\(", page)
assert "charCodeAt" in page
def test_row_status_select_carries_its_status_for_the_pill_style(page):
assert re.search(r"select\.dataset\.status\s*=", page)
assert re.search(r'select\[data-status="reading"\]', page)
def _css(page):
return "\n".join(re.findall(r"<style>(.*?)</style>", page, re.S))
def test_dark_theme_rule_redefines_colours(page):
css = _css(page)
dark = re.search(r"@media\s*\(prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([^}]*)\}", css)
assert dark, "expected @media (prefers-color-scheme: dark) { :root { ... } }"
for var in ("--paper", "--ink", "--muted", "--card"):
assert f"{var}:" in dark.group(1)
def test_reduced_motion_rule_turns_off_animation_and_transitions(page):
css = _css(page)
rule = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\}\s*\}", css, re.S)
assert rule, "expected @media (prefers-reduced-motion: reduce) { ... }"
assert re.search(r"animation:\s*none", rule.group(1))
assert re.search(r"transition:\s*none", rule.group(1))
def test_colours_are_only_defined_as_custom_properties_on_root(page):
css = _css(page)
for selector, body in re.findall(r"([^{}]*)\{([^{}]*)\}", css):
if selector.strip().endswith(":root"):
continue
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(", body), f"literal colour in {selector.strip()!r}"
def test_no_web_fonts(page):
css = _css(page)
assert "@font-face" not in css and "@import" not in css
def _rules(css):
"""Innermost CSS rules as (selector, body), including those nested in @media."""
return [(sel.strip(), body) for sel, body in re.findall(r"([^{}]*)\{([^{}]*)\}", css)]
def _bodies_for(css, fragment):
return [body for sel, body in _rules(css) if fragment in sel]
def test_text_inputs_and_button_drop_native_appearance(page):
css = _css(page)
for fragment in ('input[type="text"]', "#add-button"):
bodies = "\n".join(_bodies_for(css, fragment))
assert re.search(r"(?<!-)appearance:\s*none", bodies), f"{fragment} needs appearance: none"
assert re.search(r"-webkit-appearance:\s*none", bodies), f"{fragment} needs -webkit-appearance: none"
def test_accent_color_follows_the_theme_accent(page):
assert re.search(r"accent-color:\s*var\(--accent\)", _css(page))
def test_one_warm_focus_ring_token_in_both_themes(page):
css = _css(page)
light = re.search(r"(?<![\w-]):root\s*\{([^}]*)\}", css).group(1)
dark = re.search(r"prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([^}]*)\}", css).group(1)
assert "--focus:" in light and "--focus:" in dark
focus_rules = [(sel, body) for sel, body in _rules(css) if ":focus-visible" in sel]
assert focus_rules, "expected a :focus-visible rule"
for sel, body in focus_rules:
if "outline" in body:
assert "var(--focus)" in body, f"{sel!r} should use the shared --focus ring"
selectors = " ".join(sel for sel, _ in focus_rules)
assert ".chips input:focus-visible + label" in selectors
assert "#filter input:focus-visible + label" in selectors
def test_hover_pressed_and_disabled_states(page):
css = _css(page)
selectors = [sel for sel, _ in _rules(css)]
for needed in ("#add-button:hover", "#add-button:active", "#filter label:hover",
"#filter label:active", ".chips label:hover", ".chips label:active",
".book:hover", "#add-button:disabled", ".pill:disabled"):
assert any(needed in sel for sel in selectors), f"missing {needed}"
hover = "\n".join(_bodies_for(css, "#add-button:hover"))
assert re.search(r"transform:\s*translateY\(-", hover), "button should lift on hover"
motion = re.search(r"@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{(.*?)\}\s*\}", css, re.S).group(1)
assert re.search(r"transform:\s*none", motion), "reduced motion should drop hover lifts"
def test_add_button_shows_loading_label_without_changing_width(page):
assert re.search(r'addButton\.textContent\s*=\s*"Adding…"', page)
assert re.search(r'addButton\.textContent\s*=\s*"Add book"', page)
# width is pinned while busy so the label swap never makes the button jump
assert re.search(r"addButton\.style\.width\s*=\s*\S*getBoundingClientRect\(\)\.width", page)
assert re.search(r'addButton\.style\.width\s*=\s*""', page)
def _js_function(page, name):
"""Source of a top-level `function name(...) { ... }` from the inline script, brace-matched."""
start = page.index(f"function {name}(")
depth = 0
for i in range(page.index("{", start), len(page)):
depth += {"{": 1, "}": -1}.get(page[i], 0)
if depth == 0:
return page[start : i + 1]
raise AssertionError(f"unbalanced function {name}")
def test_spine_initials_skip_a_leading_article(page):
node = shutil.which("node")
if node is None:
pytest.skip("node is not available")
titles = ["The Left Hand of Darkness", "Dune", "A", "an Unkindness of Ghosts", "The", "Theory of Everything"]
script = _js_function(page, "initials") + f"\nconsole.log(JSON.stringify({json.dumps(titles)}.map(initials)));"
out = subprocess.run([node, "-e", script], capture_output=True, text=True, timeout=10, check=True).stdout
assert json.loads(out) == ["LH", "D", "A", "UO", "T", "TO"]
def test_spine_colour_still_hashes_the_full_title(page):
assert re.search(r"hashString\(book\.title\)", page)
def test_favicon_is_drawn_on_a_canvas_at_load(dom, page):
assert dom.find("link", rel="icon"), 'expected <link rel="icon">'
script = "\n".join(re.findall(r"<script>(.*?)</script>", page, re.S))
assert re.search(r'createElement\("canvas"\)', script)
assert re.search(r"\.width\s*=\s*64", script) and re.search(r"\.height\s*=\s*64", script)
assert re.search(r'\.href\s*=\s*\w+\.toDataURL\("image/png"\)', script)
# a failure to draw the icon must never break the page
favicon = _js_function(page, "drawFavicon")
assert re.search(r"\btry\s*\{", favicon) and re.search(r"\bcatch\b", favicon)