From booth-dev's hulda bug-hunt with heid's second voice (BRINGA, thread 01M3MVGQ7QSCCK8WT59TQ4J469): - The booth page's "kept — release" asks by name, as the Desk's does. - WORDS has no prototype: data-confirm="__proto__" or "constructor" is an unknown word, and asks, instead of throwing before preventDefault. - The confirm helper moved into <head>: its capture listener exists before any form, so a click during load is asked too (the inline confirm() it replaced had that property). - shown() also marks U+2028/U+2029 and the zero-width characters. - Derived ids take ':' (bk-ask-<id>-<key>:prompt, bk-ask-<id>:title), which no id or key can contain; '-prompt' and '-title' collided with valid keys. booth-dev's chip test now looks its fragment up by [id=...]. - human_dur says "—" for a value that is not finite, instead of raising. - The tile's copy of a note drops its id (booth-dev: mark-<id> is the panel's article). - Four guards that asserted source patterns now also hold on computed effects: embed rings, rings inside clipping containers, the withdraw × on both axes, and question-level notes fields. Contract: as_antislop S5a (fixup). Falsifiers: antislop.toml 102/102 with r2_flow.toml 24/24 proved; the full gate follows.
351 lines
15 KiB
Python
351 lines
15 KiB
Python
"""S3 of the anti-slop fix slices, measured in a real browser at phone width.
|
||
|
||
A layout claim read off a stylesheet is a guess, so these open the pages in the
|
||
same real uvicorn + Chromium harness as test_flow_browser and measure boxes. At
|
||
390x844 no text may overprint other text, and no word may be set in a column
|
||
narrower than itself. Skips, never fails, when no usable Chromium exists.
|
||
"""
|
||
|
||
import pathlib
|
||
import sys
|
||
|
||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
|
||
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
|
||
|
||
PHONE = {"width": 390, "height": 844}
|
||
|
||
|
||
def _boxes_intersect(a, b, slack=1.0):
|
||
return (min(a["r"], b["r"]) - max(a["l"], b["l"]) > slack
|
||
and min(a["b"], b["b"]) - max(a["t"], b["t"]) > slack)
|
||
|
||
|
||
_RECT = "e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }"
|
||
|
||
|
||
def _board(root: pathlib.Path):
|
||
from booth.benches import set_bench_state, upsert_bench
|
||
from booth.links import LINKS_FILE
|
||
b = root / "links"
|
||
b.mkdir()
|
||
(b / LINKS_FILE).write_text(
|
||
"- [talk, a fleet voice (HTTPS, trusted cert)](https://talk.example:8092/) <sub>· tts-dev · 2026-09-06 23:35</sub>\n"
|
||
"- [a second link](http://x.example/) <sub>· infra-ops · 2026-09-07 09:12</sub>\n")
|
||
for url, name, owner, state in [("http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev", "live"),
|
||
("http://10.100.10.50:8931/board", "svos board", "highseat-dev", "promoted"),
|
||
("http://10.100.10.50:7788/talk", "talk demo", "booth", "retired")]:
|
||
bench, _ = upsert_bench(root, url, name, owner)
|
||
set_bench_state(root, bench.id, state)
|
||
return b
|
||
|
||
|
||
def test_bench_rows_do_not_overprint_on_a_phone(browser, live):
|
||
base, root = live
|
||
_board(root)
|
||
page = browser.new_page(viewport=PHONE)
|
||
page.goto(f"{base}/b/links/", wait_until="load")
|
||
rows = page.locator(".bench-row")
|
||
assert rows.count() == 3, "positive control: the three benches render"
|
||
for i in range(rows.count()):
|
||
row = rows.nth(i)
|
||
parts = {sel: row.locator(sel).first.evaluate(_RECT)
|
||
for sel in (".bench-state", ".bench-link", ".bench-url", ".bench-who", ".bench-when", ".bench-acts")}
|
||
names = list(parts)
|
||
for x in range(len(names)):
|
||
for y in range(x + 1, len(names)):
|
||
assert not _boxes_intersect(parts[names[x]], parts[names[y]]), \
|
||
f"row {i}: {names[x]} overprints {names[y]}: {parts[names[x]]} / {parts[names[y]]}"
|
||
main = row.locator(".bench-main").evaluate(_RECT)
|
||
assert main["w"] >= 200, f"row {i}: the name's column is squeezed to {main['w']:.0f}px"
|
||
page.close()
|
||
|
||
|
||
def test_board_head_stays_compact_on_a_phone(browser, live):
|
||
base, root = live
|
||
_board(root)
|
||
page = browser.new_page(viewport=PHONE)
|
||
page.goto(f"{base}/b/links/", wait_until="load")
|
||
for sel in (".board-title", ".bench-title"):
|
||
title = page.locator(sel).evaluate(
|
||
"e => ({h: e.getBoundingClientRect().height, lh: parseFloat(getComputedStyle(e).lineHeight) || parseFloat(getComputedStyle(e).fontSize) * 1.3})")
|
||
assert title["h"] <= title["lh"] * 1.5, f"{sel} wraps: {title}"
|
||
page.close()
|
||
|
||
|
||
def test_doc_name_keeps_a_readable_line_on_a_phone(browser, live):
|
||
base, root = live
|
||
d = root / "d"
|
||
d.mkdir()
|
||
(d / "01-information-architecture.md").write_text("# The Booth\n\nA paragraph of text.\n")
|
||
page = browser.new_page(viewport=PHONE)
|
||
page.goto(f"{base}/b/d/", wait_until="load")
|
||
name = page.locator(".doc-name").first.evaluate(_RECT)
|
||
assert name["w"] >= 240, f"the doc name is set {name['w']:.0f}px wide"
|
||
page.close()
|
||
|
||
|
||
def test_file_tile_number_clears_the_download_link(browser, live):
|
||
base, root = live
|
||
f = root / "f"
|
||
f.mkdir()
|
||
(f / "ldp-demo-kit-2026-09-23-18b8cee.zip").write_bytes(b"PK\x05\x06" + b"\x00" * 18)
|
||
for vp in (PHONE, {"width": 1280, "height": 800}):
|
||
page = browser.new_page(viewport=vp)
|
||
page.goto(f"{base}/b/f/", wait_until="load")
|
||
ord_box = page.locator(".item > .ord").first.evaluate(_RECT)
|
||
text_box = page.locator(".item .dl").first.evaluate(
|
||
"e => { const r = document.createRange(); r.selectNodeContents(e); const b = r.getBoundingClientRect();"
|
||
" return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; }")
|
||
assert not _boxes_intersect(ord_box, text_box), (vp, ord_box, text_box)
|
||
page.close()
|
||
|
||
|
||
def test_review_header_is_one_line_on_a_phone(browser, live):
|
||
base, root = live
|
||
g = root / "g"
|
||
g.mkdir()
|
||
(g / "a.png").write_bytes(_png(64, 48))
|
||
(g / "b.png").write_bytes(_png(64, 48))
|
||
page = browser.new_page(viewport=PHONE)
|
||
for url in (f"{base}/b/g/view?f=a.png", f"{base}/b/g/compare?a=a.png&b=b.png"):
|
||
page.goto(url, wait_until="load")
|
||
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") == "none", url
|
||
assert page.locator(".topbar").evaluate("e => e.getBoundingClientRect().height") <= 64, url
|
||
page.goto(f"{base}/", wait_until="load")
|
||
assert page.locator(".topbar .tagline").evaluate("e => getComputedStyle(e).display") != "none", \
|
||
"the Desk keeps its tagline (negative control)"
|
||
page.close()
|
||
|
||
|
||
# ---- S4: reading measure --------------------------------------------------------
|
||
|
||
_DOC = "# The Booth\n\n## What it is\n\n### Stated once\n\n" + ("The Booth is the fleet's operator-review surface. Agents post work; the operator looks at it, judges it, and the judgment gets back to the agent. " * 4) + "\n"
|
||
|
||
|
||
def _doc_page(browser, live):
|
||
base, root = live
|
||
d = root / "d"
|
||
d.mkdir()
|
||
(d / "doc.md").write_text(_DOC)
|
||
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
||
page.goto(f"{base}/b/d/view?f=doc.md", wait_until="load")
|
||
return page
|
||
|
||
|
||
def test_doc_prose_reads_at_a_book_measure(browser, live):
|
||
page = _doc_page(browser, live)
|
||
chars = page.locator(".markdown-body p").first.evaluate("""p => {
|
||
const probe = document.createElement('span');
|
||
probe.textContent = '0'.repeat(100);
|
||
probe.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap';
|
||
p.appendChild(probe);
|
||
const ch = probe.getBoundingClientRect().width / 100;
|
||
probe.remove();
|
||
return p.getBoundingClientRect().width / ch;
|
||
}""")
|
||
assert chars <= 76, f"a paragraph runs {chars:.0f} characters across"
|
||
page.close()
|
||
|
||
|
||
def test_doc_headings_step_by_size(browser, live):
|
||
page = _doc_page(browser, live)
|
||
size = lambda sel: page.locator(f".markdown-body {sel}").first.evaluate("e => parseFloat(getComputedStyle(e).fontSize)")
|
||
body, h3, h2, h1 = size("p"), size("h3"), size("h2"), size("h1")
|
||
for lo, hi, name in ((body, h3, "h3:body"), (h3, h2, "h2:h3"), (h2, h1, "h1:h2")):
|
||
assert hi / lo >= 1.18, f"{name} is {hi / lo:.2f} ({hi}px over {lo}px)"
|
||
page.close()
|
||
|
||
|
||
|
||
# ---- S6: the hazard stripe on a pseudo-element -----------------------------------
|
||
|
||
def test_the_hazard_stripe_is_a_pseudo_element(browser, live):
|
||
base, root = live
|
||
(root / "w").mkdir()
|
||
(root / "w" / "a.txt").write_text("x")
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
page.goto(f"{base}/b/w/", wait_until="load")
|
||
got = page.locator(".wipe-lg button").evaluate("""b => {
|
||
const own = getComputedStyle(b), pre = getComputedStyle(b, '::before');
|
||
return {own: own.backgroundImage, pre: pre.backgroundImage, h: pre.height, content: pre.content};
|
||
}""")
|
||
assert got["own"] == "none", f"the button paints its own stripe: {got['own'][:80]}"
|
||
assert "repeating-linear-gradient" in got["pre"] and got["h"] == "3px" and got["content"] != "none", got
|
||
page.close()
|
||
|
||
|
||
# ---- S5a: hit areas, and Wipe now asks by name -------------------------------------
|
||
|
||
def test_withdraw_buttons_are_big_enough_to_hit(browser, live):
|
||
from booth.marks import write_note
|
||
base, root = live
|
||
g = root / "g"
|
||
g.mkdir()
|
||
(g / "a.png").write_bytes(_png(64, 48))
|
||
write_note(g, "a.png", "soft")
|
||
for ctx_args, floor in (({"viewport": {"width": 1280, "height": 800}}, 24),
|
||
({"viewport": PHONE, "is_mobile": True, "has_touch": True}, 44)):
|
||
ctx = browser.new_context(**ctx_args)
|
||
page = ctx.new_page()
|
||
page.goto(f"{base}/b/g/marks", wait_until="load") # visible at both widths
|
||
box = page.locator(".mark-x").first.evaluate(
|
||
"e => { const r = e.getBoundingClientRect(); return Math.min(r.width, r.height); }")
|
||
assert box >= floor, (ctx_args, box)
|
||
ctx.close()
|
||
|
||
|
||
def test_wipe_now_asks_by_name_in_the_browser(browser, live):
|
||
base, root = live
|
||
for name, kept in (("alpha", False), ("beta", True)):
|
||
(root / name).mkdir()
|
||
(root / name / "x.txt").write_text("x")
|
||
if kept:
|
||
(root / name / ".forever").write_text("")
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
page.goto(f"{base}/b/{name}/", wait_until="load")
|
||
said = []
|
||
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
|
||
page.locator(".wipe-lg button").click()
|
||
page.wait_for_timeout(300)
|
||
assert said and f"“{name}”" in said[0], said
|
||
assert ("KEPT" in said[0]) == kept, said[0]
|
||
assert (root / name).exists(), "dismissing the prompt must not wipe"
|
||
page.close()
|
||
|
||
|
||
def test_touch_and_scroll_behaviour(browser, live):
|
||
"""No double-tap zoom delay on a control; the film strip does not hand its
|
||
scroll to the page at its end; a long booth slug wraps on a phone instead of
|
||
running off the edge (measured on COMPUTED style, not the stylesheet)."""
|
||
base, root = live
|
||
g = root / "a-very-long-booth-name-that-runs-off-a-phone-screen-2026-09-28"
|
||
g.mkdir()
|
||
(g / "a.png").write_bytes(_png(64, 48))
|
||
(g / "b.png").write_bytes(_png(64, 48))
|
||
(g / ".booth.json").write_text('{"handle": "design-dev", "title": "Review"}') # the slug shows beside a title
|
||
page = browser.new_page(viewport=PHONE)
|
||
page.goto(f"{base}/b/{g.name}/view?f=a.png", wait_until="load")
|
||
assert page.locator(".vbar a.vx").evaluate("e => getComputedStyle(e).touchAction") == "manipulation"
|
||
assert page.locator(".film").evaluate("e => getComputedStyle(e).overscrollBehaviorX") == "contain"
|
||
page.goto(f"{base}/b/{g.name}/", wait_until="load")
|
||
slug = page.locator(".h1-slug").evaluate(
|
||
"e => ({ws: getComputedStyle(e).whiteSpace, r: e.getBoundingClientRect().right, vw: innerWidth})")
|
||
assert slug["ws"] == "normal" and slug["r"] <= slug["vw"], slug
|
||
page.close()
|
||
|
||
|
||
|
||
# ---- S5a fixup: the confirm helper, and the rings on COMPUTED style ------------------------
|
||
|
||
def _dialog_texts(page):
|
||
said = []
|
||
page.on("dialog", lambda d: (said.append(d.message), d.dismiss()))
|
||
return said
|
||
|
||
|
||
def test_release_on_the_booth_page_asks_in_the_browser(browser, live):
|
||
base, root = live
|
||
b = root / "kept"
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
(b / ".forever").write_text("")
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
page.goto(f"{base}/b/kept/", wait_until="load")
|
||
said = _dialog_texts(page)
|
||
page.locator(".keep-lg button").click()
|
||
page.wait_for_timeout(300)
|
||
page.close()
|
||
assert said and "“kept”" in said[0] and "Release" in said[0], said
|
||
assert (b / ".forever").exists(), "dismissing must not release"
|
||
|
||
|
||
def test_a_prototype_word_still_asks(browser, live):
|
||
"""A `data-confirm` naming a property every object inherits is an unknown
|
||
word, and an unknown word asks (fail closed)."""
|
||
base, root = live
|
||
b = root / "g"
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
page = browser.new_page()
|
||
page.goto(f"{base}/b/g/", wait_until="load")
|
||
said = _dialog_texts(page)
|
||
words = ["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"]
|
||
for w in words:
|
||
page.evaluate("""w => { const f = document.createElement('form');
|
||
f.method = 'post'; f.action = '/b/g/delete';
|
||
f.setAttribute('data-confirm', w); f.setAttribute('data-booth', 'g');
|
||
document.body.appendChild(f); f.requestSubmit(); f.remove(); }""", w)
|
||
page.wait_for_timeout(100)
|
||
page.close()
|
||
assert len(said) == len(words), said
|
||
assert (b / "x.txt").exists()
|
||
|
||
|
||
def test_the_dialog_shows_hidden_breaks_and_zero_widths_visibly(browser, live):
|
||
import urllib.parse
|
||
base, root = live
|
||
name = "a
b
cde"
|
||
b = root / name
|
||
b.mkdir()
|
||
(b / "x.txt").write_text("x")
|
||
page = browser.new_page()
|
||
page.goto(f"{base}/b/{urllib.parse.quote(name)}/", wait_until="load")
|
||
said = _dialog_texts(page)
|
||
page.locator(".wipe-lg button").click()
|
||
page.wait_for_timeout(300)
|
||
page.close()
|
||
assert said, "positive control: Wipe now asked"
|
||
for ch in "
":
|
||
assert ch not in said[0], (hex(ord(ch)), said[0])
|
||
assert said[0].count("�") == 5, said[0]
|
||
|
||
|
||
_RING = """e => { const cs = getComputedStyle(e);
|
||
return {fv: e.matches(':focus-visible'), style: cs.outlineStyle, width: cs.outlineWidth,
|
||
color: cs.outlineColor, offset: cs.outlineOffset}; }"""
|
||
|
||
_ALPHA = """c => { const m = c.match(/rgba?\\(([^)]+)\\)/); const p = m ? m[1].split(',') : [];
|
||
return p.length > 3 ? parseFloat(p[3]) : 1; }"""
|
||
|
||
|
||
def _keyboard_focus(page, selector):
|
||
page.keyboard.press("Shift") # keyboard modality: focus() is then :focus-visible
|
||
loc = page.locator(selector).first
|
||
loc.focus()
|
||
return loc.evaluate(_RING)
|
||
|
||
|
||
def test_rings_inside_clipping_containers_are_drawn_inside(browser, live):
|
||
from booth.benches import upsert_bench
|
||
base, root = live
|
||
g = root / "g"
|
||
g.mkdir()
|
||
(g / "a.png").write_bytes(_png(64, 48))
|
||
upsert_bench(root, "http://10.100.10.50:5173/", "peedlar desk", "peedlar-dev") # a Desk panel with a link
|
||
page = browser.new_page(viewport={"width": 1280, "height": 800})
|
||
for url, sel in (("/", ".theme button"), ("/", ".desk-panel a"), ("/b/g/", ".item a")):
|
||
page.goto(base + url, wait_until="load")
|
||
ring = _keyboard_focus(page, sel)
|
||
assert ring["fv"] and ring["offset"] == "-2px", (url, sel, ring)
|
||
page.close()
|
||
|
||
|
||
def test_the_embed_draws_visible_rings(browser, live):
|
||
from booth.marks import declare_pick
|
||
base, root = live
|
||
b = root / "r"
|
||
b.mkdir()
|
||
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
|
||
(b / "index.html").write_text(
|
||
'<!doctype html><html><head><script src="/_booth/embed.js" defer></script>'
|
||
'<style>*:focus{outline:none}</style></head>' # a host that removes rings
|
||
'<body><h1>Report</h1><div data-booth-ask="winner"></div></body></html>')
|
||
page = browser.new_page()
|
||
page.goto(f"{base}/b/r/", wait_until="networkidle")
|
||
page.wait_for_selector(".bk-ask-go", timeout=10000)
|
||
for sel in (".booth-nav-home", ".bk-ask-go"):
|
||
ring = _keyboard_focus(page, sel)
|
||
alpha = page.evaluate(_ALPHA, ring["color"])
|
||
assert ring["fv"] and ring["style"] == "solid" and ring["width"] == "2px" and alpha == 1, (sel, ring)
|
||
page.close()
|