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.
634 lines
26 KiB
Python
634 lines
26 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"]})
|
||
# 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)
|