Spaces:
Sleeping
Sleeping
Download tests/test_ui.py from pardeep-singh/reading-list: direct link, hf CLI and curl.
- Browser
- Download file 15.5 kB
-
https://huggingface.co/spaces/pardeep-singh/reading-list/resolve/main/tests/test_ui.py
- Command line
-
hf download hf://spaces/pardeep-singh/reading-list/tests/test_ui.py
-
curl -L -o test_ui.py https://huggingface.co/spaces/pardeep-singh/reading-list/resolve/main/tests/test_ui.py
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()) | |
| def page(client): | |
| return client.get("/").text | |
| 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) | |