The markup and CSS half of the anti-slop interaction work. The in-place
client is untouched (that is S5b).
- Glyph-only controls carry a name: the withdraw ×s, downloads, open full
page, the viewers' ✕, the board's pin, copy and remove, the bench's
remove, the 1:1 toggle ("1:1, natural pixels"). Film-strip and tray
frames carry the file's name as sr-only text instead of reading "01".
A Desk row's wipe names its booth.
- Fields are named by aria-label, not by their placeholder.
- The inline ask's options are a radiogroup labelled by the prompt; a
single-question fieldset gets an sr-only legend; a titled ask's title
takes bk-ask-<id>-title (it duplicated the question's id).
- One h1 per page (sr-only on the Desk, review and compare), a skip link
to <main id="main">, theme-color for light and dark.
- The review tape is one picture (role=img); its segments leave the tab
order (the film strip holds the same links, named).
- Wipe now uses the Desk's delegated prompt, moved to base.html: it names
the booth and asks the kept-booth question for a kept booth.
- Embed focus rings of its own; rings drawn inside clipping containers;
the withdraw × at least 24px, 44px under a coarse pointer;
touch-action:manipulation; strips contain their overscroll; a long
slug wraps on a phone.
- A truncated why carries its full text in title; a countdown of 48h or
more reads in days.
Two r2_flow.toml rows for the confirm helper now name base.html, where
the helper moved (anchors unchanged; the gate found them drifted).
Contract: as_antislop S5a. Falsifiers: antislop.toml 86/86 proved (S1-S6, S5a);
all 12 tables 366/366 proved on this tree.
500 lines
21 KiB
Python
500 lines
21 KiB
Python
"""The anti-slop fix slices (docs/contracts/as_antislop.contract.md).
|
||
|
||
S1, the house clock: a clock time the operator reads is local 24-hour time as
|
||
four digits with no colon (0848). Raw ISO stamps, HH:MM, microseconds, offsets
|
||
and a poster's IP address never reach visible text. The exact stored value
|
||
stays available in each <time>'s `datetime`.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import time
|
||
|
||
import pytest
|
||
|
||
from booth.marks import answer_pick, declare_pick, marks_for, write_note
|
||
|
||
PACIFIC = "America/Los_Angeles"
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def pacific():
|
||
"""Pin the zone: `clock` renders local time, and this box's local time is
|
||
the operator's. A test that inherited the runner's zone would pass or fail by
|
||
where it ran."""
|
||
old = os.environ.get("TZ")
|
||
os.environ["TZ"] = PACIFIC
|
||
time.tzset()
|
||
yield
|
||
if old is None:
|
||
os.environ.pop("TZ", None)
|
||
else:
|
||
os.environ["TZ"] = old
|
||
time.tzset()
|
||
|
||
|
||
def _now():
|
||
return time.mktime((2026, 9, 28, 12, 0, 0, 0, 0, -1))
|
||
|
||
|
||
@pytest.fixture
|
||
def client(tmp_path):
|
||
from fastapi.testclient import TestClient
|
||
|
||
from booth.app import create_app
|
||
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
|
||
|
||
|
||
def _text(fragment: str) -> str:
|
||
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", fragment)).strip()
|
||
|
||
|
||
HHMM = re.compile(r"\b\d{1,2}:\d{2}\b")
|
||
ISO = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}")
|
||
IP = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b|::1\b")
|
||
HOUSE = re.compile(r"\b\d{1,2} [A-Z][a-z]{2}(?: \d{4})? \d{4}\b")
|
||
|
||
|
||
# ---- the filters ---------------------------------------------------------------
|
||
|
||
def test_clock_forms():
|
||
from booth.app import clock
|
||
now = _now()
|
||
assert clock("2026-09-28T08:48:20.478024-07:00", now=now) == "28 Sep 0848"
|
||
assert clock("2026-09-28T15:48:20+00:00", now=now) == "28 Sep 0848" # converted to local
|
||
assert clock("2026-09-28T08:48:20", now=now) == "28 Sep 0848" # naive = local
|
||
assert clock("2026-09-06 23:35", now=now) == "6 Sep 2335" # the board's rows
|
||
assert clock("2025-09-06 23:35", now=now) == "6 Sep 2025 2335" # another year says so
|
||
assert clock(time.mktime((2026, 9, 28, 8, 48, 0, 0, 0, -1)), now=now) == "28 Sep 0848"
|
||
assert clock("", now=now) == "" and clock(None, now=now) == ""
|
||
assert clock("2026-09-28", now=now) == "28 Sep" # a date has no clock: no 0000
|
||
assert clock("2026-W39-1", now=now) == "21 Sep" # nor does an ISO week
|
||
|
||
|
||
def test_clock_never_raises():
|
||
"""The Desk and the board render many rows in ONE response: one bad stamp
|
||
must cost its own text, never the page. What cannot be read is shown as
|
||
given, never guessed."""
|
||
from booth.app import clock
|
||
for bad in ("not a time", "2026-13-45T99:99", "99999-01-01T00:00:00", 1e20, 10 ** 400, -(10 ** 400),
|
||
float("nan"), float("inf"), True, [1]):
|
||
assert isinstance(clock(bad, now=_now()), str)
|
||
assert clock("not a time", now=_now()) == "not a time"
|
||
assert clock("2026-13-45T99:99", now=_now()) == "2026-13-45T99:99"
|
||
|
||
|
||
def test_byline_hides_addresses():
|
||
from booth.app import byline
|
||
for addr in ("127.0.0.1", "10.100.10.5", "::1", "2001:db8::1", "10.100.10.5:443", "10.100.10.5/32",
|
||
"[2001:db8::1]:443", "\u200b10.100.10.5", " 127.0.0.1 "):
|
||
assert byline(addr) == "", addr
|
||
assert byline("design-dev") == "design-dev" and byline("host:8080") == "host:8080"
|
||
assert byline("") == "" and byline(None) == ""
|
||
|
||
|
||
def test_date_stamp_is_house_form():
|
||
from booth.app import date_stamp
|
||
assert re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{4}", date_stamp(_now())), date_stamp(_now())
|
||
assert date_stamp(_now()) == "2026-09-28 1200"
|
||
|
||
|
||
# ---- where they apply ------------------------------------------------------------
|
||
|
||
def _answered_booth(data):
|
||
b = data / "b"
|
||
b.mkdir()
|
||
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
|
||
answer_pick(b, "p1", marks_for(b)[0].options[0]["id"], who="127.0.0.1")
|
||
write_note(b, None, "about the booth", who="10.100.10.5")
|
||
return b
|
||
|
||
|
||
def _whens(html):
|
||
return [_text(w) for w in re.findall(r'<span class="mark-when">(.*?)</span>', html, flags=re.S)]
|
||
|
||
|
||
@pytest.mark.parametrize("path", ["/b/b/marks", "/b/b/"])
|
||
def test_rendered_marks_use_the_house_clock(client, path):
|
||
c, data = client
|
||
_answered_booth(data)
|
||
html = c.get(path).text
|
||
whens = _whens(html)
|
||
assert whens, f"{path} renders no mark line at all (positive control)"
|
||
for w in whens:
|
||
assert HOUSE.search(w), w
|
||
assert not (HHMM.search(w) or ISO.search(w) or IP.search(w)), w
|
||
visible = _text(re.sub(r"<(script|style)\b.*?</\1>", " ", html, flags=re.S))
|
||
assert "127.0.0.1" not in html and "10.100.10.5" not in html, "no address anywhere, attributes included"
|
||
stored = {m.created for m in marks_for(data / "b")} | {m.answer["answered_at"] for m in marks_for(data / "b") if m.answer}
|
||
carried = set(re.findall(r'<time datetime="([^"]+)"', html))
|
||
assert stored & carried, "the exact stored stamp rides in datetime="
|
||
|
||
|
||
def test_embed_fragment_uses_the_house_clock(client):
|
||
c, data = client
|
||
_answered_booth(data)
|
||
(m,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
|
||
# The SUBMIT block's tag carries the stamp (the per-question tag says only
|
||
# "✓ answered"); the embed places it from `submit`.
|
||
tag = _text(re.search(r'<span class="bk-ask-tag">(.*?)</span>', m["submit"], flags=re.S).group(1))
|
||
assert tag.startswith("✓ answered"), tag
|
||
assert HOUSE.search(tag) and not (ISO.search(tag) or HHMM.search(tag)), tag
|
||
|
||
|
||
def test_board_rows_use_the_house_clock(client):
|
||
from booth.links import LINKS_FILE
|
||
c, data = client
|
||
board = data / "links"
|
||
board.mkdir()
|
||
(board / LINKS_FILE).write_text("- [Booth](http://x/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n"
|
||
"- [Other](http://y/) <sub>· 10.0.0.5 · 2026-09-06 23:36</sub>\n")
|
||
html = c.get("/b/links/").text
|
||
whens = [_text(w) for w in re.findall(r'<span class="board-when">(.*?)</span>', html, flags=re.S)]
|
||
assert "10.0.0.5" not in html, "a board row's author is bylined like every other"
|
||
assert len(whens) == 2 and all(HOUSE.search(w) and not HHMM.search(w) for w in whens), whens
|
||
assert "6 Sep 2335" in whens, whens
|
||
assert 'datetime="2026-09-06 23:35"' in html
|
||
|
||
|
||
def test_a_malformed_answer_costs_its_line_not_the_page(client):
|
||
"""One stored answer missing `unanswered` must not 500 the booth or marks
|
||
page (heid bug-hunt Q3). REFUTED as a live bug: the Booth's Jinja uses the
|
||
default `Undefined`, whose `|length` is 0. Kept as a guard: switching the
|
||
environment to StrictUndefined would turn this red."""
|
||
import json as _json
|
||
c, data = client
|
||
b = data / "b"
|
||
b.mkdir()
|
||
(b / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||
declare_pick(b, "p1", {"prompt": "Which?", "options": ["North", "South"]})
|
||
doc = _json.loads((b / ".marks.json").read_text())
|
||
doc["marks"][0]["answer"] = {"answers": []}
|
||
(b / ".marks.json").write_text(_json.dumps(doc))
|
||
for url in ("/b/b/", "/b/b/marks"):
|
||
assert c.get(url).status_code == 200, url
|
||
|
||
|
||
# ---- S2: legibility ----------------------------------------------------------------
|
||
# Faded is not legible; labels >= 11px (--size-micro); sentence-like lines >= 12px
|
||
# (--size-caption). These read the shipped CSS, and the arrow test composites the
|
||
# colours with INDEPENDENT maths (OKLCH -> sRGB -> WCAG), not with anything the
|
||
# page computes.
|
||
|
||
import math as _math
|
||
from pathlib import Path as _Path
|
||
|
||
_T = _Path(__file__).resolve().parent.parent / "booth" / "templates"
|
||
_EMBED = _Path(__file__).resolve().parent.parent / "booth" / "static" / "embed.js"
|
||
|
||
|
||
def _rule(css: str, selector: str) -> str:
|
||
m = re.search(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)
|
||
assert m, f"no rule for {selector!r}"
|
||
return m.group(1)
|
||
|
||
|
||
def _oklch_srgb(l, c, h):
|
||
a, b = c * _math.cos(_math.radians(h)), c * _math.sin(_math.radians(h))
|
||
l_, m_, s_ = (l + 0.3963377774 * a + 0.2158037573 * b, l - 0.1055613458 * a - 0.0638541728 * b,
|
||
l - 0.0894841775 * a - 1.2914855480 * b)
|
||
L, M, S = l_ ** 3, m_ ** 3, s_ ** 3
|
||
lin = (4.0767416621 * L - 3.3077115913 * M + 0.2309699292 * S,
|
||
-1.2684380046 * L + 2.6097574011 * M - 0.3413193965 * S,
|
||
-0.0041960863 * L - 0.7034186147 * M + 1.7076147010 * S)
|
||
enc = lambda v: 12.92 * v if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055
|
||
return [max(0.0, min(1.0, enc(v))) for v in lin]
|
||
|
||
|
||
def _lum(rgb):
|
||
f = lambda v: v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
|
||
return 0.2126 * f(rgb[0]) + 0.7152 * f(rgb[1]) + 0.0722 * f(rgb[2])
|
||
|
||
|
||
def _oklch(decl: str, prop: str):
|
||
m = re.search(prop + r"\s*:\s*oklch\(([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*/\s*([\d.]+))?\)", decl)
|
||
assert m, f"{prop} is not an oklch() colour in {decl!r}"
|
||
return _oklch_srgb(*map(float, m.groups()[:3])), float(m.group(4) or 1)
|
||
|
||
|
||
def test_review_arrows_hold_over_a_white_stage():
|
||
"""The worst stage is white: composite the chip over it in sRGB, as the
|
||
browser does, and measure the glyph against the result."""
|
||
decl = _rule((_T / "view.html").read_text(), ".vnav")
|
||
glyph, _ = _oklch(decl, r"(?<![-\w])color")
|
||
chip, alpha = _oklch(decl, "background")
|
||
under = [alpha * c + (1 - alpha) * 1.0 for c in chip]
|
||
a, b = _lum(glyph), _lum(under)
|
||
ratio = (max(a, b) + 0.05) / (min(a, b) + 0.05)
|
||
assert ratio >= 7, f"arrow glyph over the chip over white: {ratio:.2f}:1"
|
||
|
||
|
||
def _px(decl: str) -> float:
|
||
m = re.search(r"font(?:-size)?\s*:[^;]*?(?:(\d+(?:\.\d+)?)px|var\(--size-(micro|caption|sm|body)\))", decl)
|
||
assert m, decl
|
||
return float(m.group(1)) if m.group(1) else {"micro": 11, "caption": 12, "sm": 13, "body": 14}[m.group(2)]
|
||
|
||
|
||
def test_film_numbers_meet_the_label_floor():
|
||
assert _px(_rule((_T / "base.html").read_text(), ".film-ord")) >= 11
|
||
|
||
|
||
def test_mark_state_meets_the_label_floor():
|
||
assert _px(_rule((_T / "base.html").read_text(), ".mark-state")) >= 11
|
||
|
||
|
||
@pytest.mark.parametrize("selector", [".desk-rule", ".board-note", ".bench-note"])
|
||
def test_hint_lines_meet_the_sentence_floor(selector):
|
||
css = (_T / "base.html").read_text()
|
||
decls = [m.group(1) for m in re.finditer(r"(?:^|[}\s])" + re.escape(selector) + r"\s*\{([^}]*)\}", css)]
|
||
sizes = [_px(d) for d in decls if "font" in d]
|
||
assert sizes and min(sizes) >= 12, (selector, sizes)
|
||
|
||
|
||
def test_retired_benches_are_not_faded():
|
||
css = (_T / "base.html").read_text()
|
||
for m in re.finditer(r"([^{}]*is-retired[^{}]*)\{([^}]*)\}", css):
|
||
assert "opacity" not in m.group(2), m.group(0)
|
||
|
||
|
||
def _js_rule(js: str, selector: str) -> str:
|
||
"""embed.js carries its sheet as JS string literals: `".sel{...}"`."""
|
||
m = re.search(r'"' + re.escape(selector) + r"\{([^}]*)\}", js)
|
||
assert m, f"no rule for {selector!r} in embed.js"
|
||
return m.group(1)
|
||
|
||
|
||
def test_embed_fades_nothing():
|
||
js = _EMBED.read_text()
|
||
for sel in (".bk-ask-det", ".bk-ask-was", ".bk-ask-title"):
|
||
decl = _js_rule(js, sel)
|
||
assert "opacity" not in decl, (sel, decl)
|
||
assert not re.search(r'"\.bk-ask-was b\{', js), "a child restoring opacity means the parent fades"
|
||
ph = _js_rule(js, ".bk-ask-notes::placeholder")
|
||
assert "color:inherit" in ph.replace(" ", ""), ph
|
||
|
||
|
||
def test_the_ask_tag_meets_the_label_floor():
|
||
"""The inline ask's state tag is a label (`? your pick`, `✓ answered 28 Sep 0848`).
|
||
Its sheet is split over JS string literals, so read the font line that follows
|
||
the selector."""
|
||
js = _EMBED.read_text()
|
||
i = js.index('".bk-ask-tag{')
|
||
m = re.search(r"font:\d+ (\d+(?:\.\d+)?)px", js[i:i + 400])
|
||
assert m and float(m.group(1)) >= 11, m and m.group(0)
|
||
|
||
|
||
|
||
# ---- S6: the operator's rulings ------------------------------------------------------
|
||
|
||
def test_the_tagline_is_a_sentence(client):
|
||
c, data = client
|
||
html = c.get("/").text
|
||
m = re.search(r'<span class="tagline">(.*?)</span>', html, flags=re.S)
|
||
assert m and _text(m.group(1)) == "held for review · wipes in 24h unless kept", m and m.group(1)
|
||
# the BASE rule (the phone rule `body.page-stage .topbar .tagline` is S3's)
|
||
decl = re.search(r"\n\s*\.tagline\{([^}]*)\}", (_T / "base.html").read_text()).group(1)
|
||
assert "uppercase" not in decl and _px(decl) >= 12, decl
|
||
|
||
|
||
def test_needs_you_rows_carry_no_side_stripe():
|
||
decl = _rule((_T / "base.html").read_text(), ".desk-row.is-needs")
|
||
assert "inset" not in decl, decl
|
||
|
||
|
||
def test_the_brand_dot_is_matte():
|
||
decl = _rule((_T / "base.html").read_text(), ".brand .dot")
|
||
assert "box-shadow" not in decl, decl
|
||
|
||
|
||
# ---- S5a: accessibility plumbing (markup and CSS) --------------------------------
|
||
# Written after the code: each test is proved by its row in antislop.toml, which
|
||
# runs the change it forbids and must see it fail.
|
||
|
||
import html as _html
|
||
|
||
|
||
def _s5_booth(data):
|
||
"""A booth that renders every control the S5a claims cover: a doc, a file, an
|
||
image with a note and a flag, a titled single-question ask, and a board."""
|
||
from booth.marks import set_flag
|
||
b = data / "b"
|
||
b.mkdir()
|
||
(b / "notes.md").write_text("# Notes\n\ntext\n")
|
||
(b / "kit.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
|
||
(b / "a.png").write_bytes(_PNG)
|
||
(b / "c.png").write_bytes(_PNG)
|
||
set_flag(b, "a.png", True)
|
||
write_note(b, "a.png", "soft edges")
|
||
write_note(b, None, "about the booth")
|
||
declare_pick(b, "p1", {"title": "Round one", "prompt": "Which?", "options": ["North", "South"]})
|
||
_s5_board(data)
|
||
return b
|
||
|
||
|
||
def _s5_board(data):
|
||
"""The link board, with a row and a bench: its controls are named too."""
|
||
from booth.benches import upsert_bench
|
||
from booth.links import LINKS_FILE
|
||
(data / "links").mkdir()
|
||
(data / "links" / LINKS_FILE).write_text("- [Booth](http://x.example/) <sub>· infra-ops · 2026-09-06 23:35</sub>\n")
|
||
upsert_bench(data, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev")
|
||
|
||
|
||
_PNG = (b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef\x00\x00\x00\x00IEND\xaeB`\x82")
|
||
|
||
|
||
def _pages(c):
|
||
return {u: c.get(u).text for u in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png",
|
||
"/b/b/marks", "/b/b/view?f=notes.md", "/b/links/")}
|
||
|
||
|
||
def _markup(page):
|
||
"""The page's own markup: script and style bodies are strings and comments
|
||
are prose (the stylesheet's comments name `<textarea>`), not elements."""
|
||
page = re.sub(r"<(script|style)\b.*?</\1>", " ", page, flags=re.S)
|
||
return re.sub(r"<!--.*?-->", " ", page, flags=re.S)
|
||
|
||
|
||
def _name(attrs, inner):
|
||
"""The accessible name, as far as markup decides it: aria-label wins, else
|
||
the text, counting each image's alt (an <img alt="a.png"> names its link)."""
|
||
m = re.search(r'aria-label="([^"]*)"', attrs)
|
||
if m:
|
||
return _html.unescape(m.group(1))
|
||
inner = re.sub(r'<img\b[^>]*\balt="([^"]*)"[^>]*>', r" \1 ", inner)
|
||
return _html.unescape(_text(inner))
|
||
|
||
|
||
def _controls(page):
|
||
for m in re.finditer(r"<(a|button)\b([^>]*)>(.*?)</\1>", _markup(page), flags=re.S):
|
||
if 'aria-hidden="true"' in m.group(2):
|
||
continue # a deliberate duplicate (the Desk's thumbnail link) is not a control
|
||
yield m.group(2), _name(m.group(2), m.group(3))
|
||
|
||
|
||
def test_every_control_has_a_word_for_a_name(client):
|
||
"""A control a screen reader announces as "×", "↗" or "01" has no name. Every
|
||
link and button's name, as the markup decides it, carries a word."""
|
||
c, data = client
|
||
_s5_booth(data)
|
||
for url, page in _pages(c).items():
|
||
seen = 0
|
||
for attrs, name in _controls(page):
|
||
seen += 1
|
||
assert re.search(r"[A-Za-z]", name), f"{url}: a control named {name!r}: {attrs[:120]}"
|
||
assert seen, f"{url}: no control found (positive control)"
|
||
|
||
|
||
def test_desk_row_controls_name_their_booth(client):
|
||
c, data = client
|
||
for n in ("alpha", "beta"):
|
||
(data / n).mkdir()
|
||
(data / n / "x.txt").write_text("x")
|
||
labels = re.findall(r'aria-label="(wipe the (?:kept )?booth [^"]+)"', c.get("/").text)
|
||
assert len(labels) == 2 and len(set(labels)) == 2 and any("alpha" in s for s in labels), labels
|
||
|
||
|
||
def test_fields_are_named(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
pages = _pages(c)
|
||
pages["embed"] = " ".join(m["whole"] + m["submit"] for m in c.get("/b/b/embed.json").json()["marks"])
|
||
for url, page in pages.items():
|
||
for m in re.finditer(r"<(textarea|input)\b([^>]*)>", _markup(page), flags=re.S):
|
||
attrs = m.group(2)
|
||
if re.search(r'type="(hidden|radio|checkbox|submit)"', attrs):
|
||
continue
|
||
ok = "aria-label=" in attrs or re.search(r'id="([^"]+)"', attrs) and \
|
||
re.search(r'<label[^>]*for="%s"' % re.escape(re.search(r'id="([^"]+)"', attrs).group(1)), page)
|
||
assert ok, f"{url}: an unnamed field: {attrs[:120]}"
|
||
|
||
|
||
def test_radio_groups_are_named_and_ids_are_unique(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||
# The embed places an ask one of two ways: `whole` (title, questions and
|
||
# submit in one piece), or its questions one by one plus `submit`. Never both.
|
||
placements = {"whole": m["whole"], "parts": "".join(q["html"] for q in m["questions"]) + m["submit"]}
|
||
for how, frag in placements.items():
|
||
groups = re.findall(r'role="radiogroup" aria-labelledby="([^"]+)"', frag)
|
||
assert groups and all(f'id="{g}"' in frag for g in groups), (how, groups)
|
||
# the ASK ids are this slice's (a titled single ask emitted `bk-ask-<id>`
|
||
# twice). Mark ids (`mark-note-1`) also repeat across the tile and the
|
||
# aside; the CLI prints `#mark-<id>` links to them, so that one is
|
||
# reported, not changed here.
|
||
ids = re.findall(r'\bid="(bk-ask-[^"]+)"', frag)
|
||
dupes = {i for i in ids if ids.count(i) > 1}
|
||
assert ids and not dupes, (how, f"duplicate ask ids {sorted(dupes)[:5]}")
|
||
marks = c.get("/b/b/marks").text
|
||
for fs in re.findall(r"<fieldset\b.*?</fieldset>", marks, flags=re.S):
|
||
if 'type="radio"' in fs:
|
||
assert "<legend" in fs, fs[:160]
|
||
|
||
|
||
def test_every_page_has_one_h1_and_a_skip_link(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
for url in ("/", "/b/b/", "/b/b/view?f=a.png", "/b/b/compare?a=a.png&b=c.png", "/b/b/marks"):
|
||
page = re.sub(r'<article class="markdown-body.*?</article>', " ", c.get(url).text, flags=re.S) # a doc's own h1 is content
|
||
assert len(re.findall(r"<h1\b", page)) == 1, (url, len(re.findall(r"<h1\b", page)))
|
||
assert re.search(r'<a class="skip-link" href="#main">', page) and 'id="main"' in page, url
|
||
|
||
|
||
def test_theme_color_for_both_schemes(client):
|
||
c, _ = client
|
||
page = c.get("/").text
|
||
for scheme in ("light", "dark"):
|
||
assert re.search(r'<meta name="theme-color" media="\(prefers-color-scheme: %s\)" content="#[0-9a-f]{6}">' % scheme, page), scheme
|
||
|
||
|
||
def test_the_tape_is_one_picture(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
page = c.get("/b/b/view?f=a.png").text
|
||
assert re.search(r'<div class="tape" data-region="tape" role="img" aria-label="', page)
|
||
segs = re.findall(r'<a class="tape-s[^>]*>', page, flags=re.S)
|
||
assert segs and all('tabindex="-1"' in s and 'aria-hidden="true"' in s for s in segs), segs[:2]
|
||
|
||
|
||
def test_wipe_now_asks_by_name(client):
|
||
c, data = client
|
||
_s5_booth(data)
|
||
page = c.get("/b/b/").text
|
||
form = re.search(r'<form class="wipe wipe-lg"[^>]*>', page, flags=re.S).group(0)
|
||
assert 'data-booth="b"' in form and 'data-confirm="wipe"' in form and "onsubmit" not in form, form
|
||
assert "var WORDS = {" in page, "the shared confirm helper is on the booth page"
|
||
|
||
|
||
def test_human_dur_rolls_up_to_days():
|
||
from booth.app import human_dur
|
||
assert human_dur(47 * 3600) == "47h"
|
||
assert human_dur(48 * 3600) == "2d"
|
||
assert human_dur(167 * 3600 + 12 * 60) == "6d 23h"
|
||
|
||
|
||
def test_embed_chrome_draws_its_own_focus_rings():
|
||
js = _EMBED.read_text()
|
||
assert re.search(r'"\.booth-nav-home:focus-visible,\.booth-nav-asks:focus-visible\{outline:2px solid', js)
|
||
assert re.search(r'"\.bk-ask-go:focus-visible\{outline:2px solid', js)
|
||
assert re.search(r'"\.bk-ask-opt:has\(input:focus-visible\)\{outline:2px solid', js)
|
||
|
||
|
||
def test_focus_rings_are_drawn_inside_clipping_containers():
|
||
css = (_T / "base.html").read_text()
|
||
m = re.search(r"\.theme button:focus-visible,\.vtoggle button:focus-visible,\.item a:focus-visible,\s*\.desk-panel a:focus-visible\{outline-offset:-2px\}", css)
|
||
assert m
|
||
|
||
|
||
def test_a_truncated_why_carries_its_full_text(client):
|
||
c, data = client
|
||
b = data / "w"
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
(b / ".booth.json").write_text('{"handle": "design-dev", "why": "a long reason that the Desk truncates with an ellipsis"}')
|
||
page = c.get("/").text
|
||
assert re.search(r'<span class="prov-why" title="a long reason that the Desk truncates with an ellipsis">', page)
|