Files
booth/tests/test_antislop.py
T
vh 0233ca64fb fix(as-S5b): focus survives a swap, a status line you can see, drafts that ask before they go
The in-place client half of the anti-slop interaction work (guidelines
G1, G2, G4, G13). It still never re-POSTs, still serializes saves, and a
batch still never reloads.
- Focus: the focused element is recorded by identity (its region, its
  key, which same-key element it was) and the fresh one is focused
  without scrolling. If an answered pick's form folds into a closed
  <details>, focus goes to its summary; if nothing is left, to the region
  (tabindex=-1, set by the script). Focus outside the swapped regions
  is not touched.
- One status line per page (_status.html). It floats at the bottom
  centre, above the fixed review stage, so it moves nothing and is in view
  wherever the reader is. Wider than 900px, the letterhead and footer the
  review covers leave the Tab order (visibility:hidden, CSS only).
- The line is never hidden: empty, it takes no space and stays displayed.
  "Saving…" at the press, "Still saving…" on a repeat press, "Saved." when
  the swap lands (cleared after 2s if still the same write), and warnings
  with data-tone="warn". Every write sets or clears the tone. The form in
  flight carries aria-busy until its save settles.
- The client never reloads over a draft: both of its reloads run only
  when every in-place form is clean except the one just sent, unchanged
  since its press, asked again at the reload beat; otherwise it says so
  and stays. A beforeunload guard asks when an in-place form is dirty (its
  own reload does not ask). The embed asks when one of our answers is
  unsent, and skips the pressed form on its own one-form submit.
- Six booth-dev browser tests read the line's hidden state; they read
  its words and tone instead. Two r2_submit_all.toml rows are re-anchored
  to the same failure in the moved code.
Folded from the heid bug-hunt (panel 4/4, thread 01M3MRTNTWEPJHTN4APRR81KH4):
- aria-busy mirrors which forms are in flight on the LIVE page. It is set
  at the press and re-synced whenever a save settles, so it ends on every
  path (a stale tile the swap never replaced included), and a queued form
  replaced by an earlier swap is marked busy again.
- A press inside the reload beat cancels the reload.
- A failure that stayed is said again after an unrelated save, rather
  than buried under "Saved.".
- A 204 followed by a failed page GET is "Saved.", never "could not save".
- An edit made while its save flew is said to be unsaved.
- A focused <summary> has a key.
- The queue settles on rejection.
- The embed's skip covers the one navigation its submit starts; a
  cancelled submit, or one that leaves the page in place, is guarded
  again.
- Pinned: no in-place form holds a control dirty() cannot read, and no
  region nests in another.
Folded from this slice's gate: the status line floats (fixed, bottom
centre, above the review stage) instead of sitting at the top of <main>
or under the viewer's bar. In the flow, every save's "Saving…" moved the
page; booth-dev's test_a_flag_lands_in_place_and_every_region_catches_up
caught a 50px jump. The viewers' grids are back as they were.

Contract: as_antislop S5b (heid contract review and bug-hunt folded).
Falsifiers: antislop.toml S5b sections.
2026-09-28 15:42:37 -07:00

634 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"]})
# S5a fixup: a multi-question ask with per-question notes, and keys that end
# like the ids S5a derives from them (`-prompt`, `title`)
declare_pick(b, "p2", {"title": "Round two", "questions": [
{"key": "x-prompt", "prompt": "First?", "options": ["keep", "cut"], "notes": True},
{"key": "x", "prompt": "Second?", "options": ["keep", "cut"], "notes": True},
{"key": "title", "prompt": "Third?", "options": ["keep", "cut"]}]})
_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,) = [m for m in c.get("/b/b/embed.json").json()["marks"] if m["id"] == "p1"]
# 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 = Object.create(null);" 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)
# ---- S5a fixup: booth-dev's gate (hulda bug-hunt + heid BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469) ----
def test_no_id_repeats_on_any_page(client):
"""An id names one element. The tile's copy of a note used to repeat the
panel article's `mark-<id>` (booth-dev: the id is the article's); a question
key ending in `-prompt`, or named `title`, repeated the ids S5a derived."""
c, data = client
_s5_booth(data)
pages = _pages(c)
for m in c.get("/b/b/embed.json").json()["marks"]:
pages[f"embed {m['id']} whole"] = m["whole"]
pages[f"embed {m['id']} parts"] = "".join(q["html"] for q in m["questions"]) + m["submit"]
for url, page in pages.items():
ids = re.findall(r'\sid="([^"]+)"', _markup(page))
dupes = sorted({i for i in ids if ids.count(i) > 1})
assert not dupes, (url, dupes[:5])
for ref in re.findall(r'aria-labelledby="([^"]+)"', page):
assert f'id="{ref}"' in page, (url, ref)
def test_release_on_the_booth_page_asks_by_name(client):
c, data = client
b = data / "k"
b.mkdir()
(b / "x.txt").write_text("x")
(b / ".forever").write_text("")
page = c.get("/b/k/").text
form = re.search(r'<form class="keep-lg"[^>]*>', page).group(0)
assert 'data-booth="k"' in form and 'data-confirm="release"' in form, form
def test_the_confirm_helper_is_listening_before_the_body_exists(client):
"""A click while the page is still loading must be asked too: the capture
listener is registered in <head>, not after the footer."""
c, data = client
_s5_booth(data)
for url in ("/", "/b/b/"):
page = c.get(url).text
assert page.index("function shown(n)") < page.index("</head>"), url
def test_human_dur_never_raises():
from booth.app import human_dur
for bad in (float("nan"), float("inf"), float("-inf")):
assert isinstance(human_dur(bad), str), bad
# ---- S5b: one status line per page, never hidden --------------------------------
def test_one_status_line_per_page(client):
"""Exactly one `data-region="status"` on every kind of page, never `hidden`,
and inside no other `data-region` (a swap would replace it mid-message)."""
from html.parser import HTMLParser
from booth.links import LINKS_FILE
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Lines(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.found = [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if a.get("data-region") == "status":
self.found.append((tag, a, [r for r in self.stack if r]))
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag not in void and self.stack:
self.stack.pop()
assert (data / "links" / LINKS_FILE).exists()
for url, page in _pages(c).items():
p = Lines()
p.feed(page)
assert len(p.found) == 1, (url, len(p.found))
tag, attrs, outer = p.found[0]
assert "hidden" not in attrs, url
assert attrs.get("role") == "status" and attrs.get("aria-live") == "polite", (url, attrs)
assert not outer, (url, outer)
def test_the_in_place_client_can_read_every_page(client):
"""What the in-place client assumes about the pages it swaps (heid bug-hunt
R7, R11, accepted as true today and pinned here): no in-place form holds a
control `dirty()` cannot read (a <select>, or an input that is not text,
radio, checkbox, hidden or submit), and no `data-region` sits inside another
(a nested region is replaced inside a detached tree)."""
from html.parser import HTMLParser
c, data = client
_s5_booth(data)
void = {"input", "img", "br", "meta", "link", "hr", "source", "wbr", "col", "area", "base", "embed", "track"}
class Pages(HTMLParser):
def __init__(self):
super().__init__()
self.stack, self.inplace, self.odd, self.nested = [], 0, [], []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "form" and "data-inplace" in a:
self.inplace += 1
if self.inplace and (tag == "select" or (tag == "input" and a.get("type", "text")
not in ("hidden", "radio", "checkbox", "text", "submit"))):
self.odd.append((tag, a.get("type")))
if a.get("data-region") and any(self.stack):
self.nested.append(a["data-region"])
if tag not in void:
self.stack.append(a.get("data-region"))
def handle_endtag(self, tag):
if tag == "form" and self.inplace:
self.inplace -= 1
if tag not in void and self.stack:
self.stack.pop()
for url, page in _pages(c).items():
p = Pages()
p.feed(page)
assert not p.odd, (url, p.odd)
assert not p.nested, (url, p.nested)