Cross-frontier panel (Gróa/Hulda/Regin/Kimi) on U7's diff, thread 01M368G2Y0JMTJ2T7M3JMTXV5Z. Four of the six fixes are for defects no test in this repo could have caught, and the panel's guard-strength passes found five of my own falsifiers green under the exact change they forbade. THE 4-OF-4 FINDING — the group anchor could land on the WRONG artifact. The anchor was the raw rel spliced into an href fragment while the tile id was equally raw. A browser matches a fragment against ids RAW FIRST and only then percent-decoded, so raw-on-both-sides is not merely unencoded, it is AMBIGUOUS: with `a b.png` and `a%20b.png` in one booth, the first's href resolves to the fragment `item-a%20b.png` and the raw pass matches the SECOND file's id. That is the misfiled-judgment failure invariant 6 exists to prevent, arriving through a path invariant 6 never looked at. Both sides now use `Item.url` (`quote(rel, safe="/")`), which is injective here and is the convention booth_flag has always used. The original test asserted the href occurred as SOME id on the page — true while pointing at the wrong one. GRÓA'S STRONGEST SOLO — a zero-hit filter removed the way back. The rail was gated on the FILTERED list, so a valid filter with no matches removed the rail, the filter links and the route back to `all`, while the empty-booth branch announced the booth was empty with rail.total still holding the real count. No recovery without editing the address bar, and it degraded the same way with JavaScript off, on the surface the operator actually reviews on. Gated on all_items now, with an explicit no-match row. HULDA — one unrepresentable filename took out the INDEX, not just its booth. A non-UTF-8 filename reaches CPython as a surrogate and quote() raises on it, outside any per-item handler. booth_items feeds list_booths, so one 0xff byte in one booth's filename 500s every booth's card. Such a file cannot be linked, served or zipped, so it is skipped like a dotfile. HULDA — the `f` shortcut has never worked. The selector named `.flagbtn`, which nothing in this repo emits, so it fell through to the hidden target input; clicking a hidden input does not submit its form, and the handler called preventDefault anyway. Now clicks the flag form's real button, verified end to end in a real browser. GRÓA — a group jump was undone by the next keypress. The jump scrolls, the cursor stayed at -1, and the next arrow focused tile 0 and scrolled back. The cursor now picks up from the viewport, which also fixes the general scroll-then-arrow case. Asserted on real scroll geometry in Chromium. HULDA — the caption sidecar was read whole before being truncated, so a pathological file was a MemoryError the OSError handler does not catch. Bounded at the read, and deliberately NOT by st_size: a FIFO reports 0. ACCEPTED KNOWN RISKS, both now documented rather than implied: no cap on rail row count (1,000 groups of two would render 1,000 rows; the largest live booth is 66 items and picking a cap without a booth that needs one is invented work), and Item.group sits mid-dataclass (one construction site, keyword-only, grepped). The docstring now names the UPPER median explicitly — two arms flagged that "the middle group" admits both readings for an even count. FIVE VACUOUS FALSIFIERS, found by the arms and not by me: the anchor test survived v[0]->v[-1]; the informativeness guard survived sizes[-1]; the group count survived len(v)+1; the zero-hit filter test used a fixture that HAD hits; and the escaping test asserted over the whole page, so it went red on a code comment. All rewritten, all mutation-proved. The table is up to 20 rows and one drifted when I changed the line under it — reported by the harness, not silently skipped, which is the behaviour tests/test_mutation_check.py exists to hold. 660 green; 20/20 proved. Deployed; 21/21 booths 200. Held for design-dev, not fixed here: Gróa's finding that the sticky rail has no scroll-margin, so a fragment jump tucks the target under it. It is one line in base.html, the file he is rewriting from scratch.
609 lines
26 KiB
Python
609 lines
26 KiB
Python
"""U3 — the declared embed seam, in a real DOM.
|
|
|
|
The Python suite can prove what the server OFFERS. It cannot prove where a
|
|
fragment lands, whether the author's own markup survived the mount, or whether
|
|
four radio groups scattered down a report still submit as one POST — and that
|
|
last one is the operator's most important workflow. Before U3 those properties
|
|
were true by construction, because the server did the placing and the `form=`
|
|
bindings were static by the time the page was parsed. Now they are true because
|
|
`/_booth/embed.js` does it in a live document, which is a different kind of
|
|
claim and needs a different kind of test.
|
|
|
|
So: a real uvicorn on an ephemeral port, a real Chromium.
|
|
|
|
SKIPS, NEVER FAILS, when playwright or the shared browser is unavailable. The
|
|
box-wide store at /opt/ms-playwright pins specific Chromium revisions and a
|
|
playwright release that wants a newer one dies with an opaque "Executable
|
|
doesn't exist" — see pyproject's version bound. A test layer that goes red for
|
|
an environment reason teaches nothing and trains people to ignore it.
|
|
"""
|
|
import json
|
|
import socket
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from booth.app import create_app
|
|
|
|
playwright_api = pytest.importorskip(
|
|
"playwright.sync_api", reason="playwright is not installed"
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def browser():
|
|
with playwright_api.sync_playwright() as pw:
|
|
try:
|
|
b = pw.chromium.launch()
|
|
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
|
|
pytest.skip(f"no usable chromium: {exc}")
|
|
yield b
|
|
b.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def live(tmp_path):
|
|
"""A real server, because a browser cannot talk to a TestClient."""
|
|
import uvicorn
|
|
|
|
sock = socket.socket()
|
|
sock.bind(("127.0.0.1", 0))
|
|
port = sock.getsockname()[1]
|
|
sock.close()
|
|
|
|
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
|
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
|
server = uvicorn.Server(config)
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
deadline = time.time() + 10
|
|
while not server.started and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
if not server.started:
|
|
pytest.skip("uvicorn did not come up")
|
|
try:
|
|
yield f"http://127.0.0.1:{port}", tmp_path
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=10)
|
|
|
|
|
|
PNG = b"\x89PNG\r\n\x1a\n"
|
|
SEAM = '<script src="/_booth/embed.js" defer></script>'
|
|
|
|
|
|
def _multi(booth):
|
|
from booth.marks import declare_pick
|
|
|
|
booth.mkdir(parents=True, exist_ok=True)
|
|
declare_pick(booth, "batch", {"title": "Round one", "questions": [
|
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
|
{"key": "r2", "prompt": "Second?", "options": ["keep", "cut"]},
|
|
]})
|
|
return booth
|
|
|
|
|
|
def _single(booth):
|
|
from booth.marks import declare_pick
|
|
|
|
booth.mkdir(parents=True, exist_ok=True)
|
|
declare_pick(booth, "winner", {"prompt": "Which render wins?",
|
|
"options": ["A — baseline", "B — async"]})
|
|
return booth
|
|
|
|
|
|
def _open(browser, base, name, html, booth):
|
|
(booth / "index.html").write_text(html, encoding="utf-8")
|
|
page = browser.new_page()
|
|
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
|
|
return page
|
|
|
|
|
|
def _answer(booth):
|
|
raw = json.loads((booth / ".marks.json").read_text())
|
|
return raw["marks"][0].get("answer")
|
|
|
|
|
|
# ---- the chrome --------------------------------------------------------------
|
|
|
|
|
|
def test_a_declaring_page_gets_its_chrome_mounted(browser, live):
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
page = _open(browser, base, "b", f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>", b)
|
|
page.wait_for_selector(".booth-nav-home")
|
|
assert page.locator("h1").inner_text() == "R" # the report is intact
|
|
assert page.locator(".booth-nav-home").get_attribute("href").endswith("/")
|
|
# the favicon question, asked of a parsed document instead of raw text
|
|
assert page.locator('link[rel="icon"]').count() == 1
|
|
page.close()
|
|
|
|
|
|
def test_a_page_that_never_declared_the_seam_still_mounts(browser, live):
|
|
"""The appended path: every verbatim booth that predates U3 keeps working
|
|
without its author touching it."""
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
page = _open(browser, base, "b", "<!doctype html><body><h1>OLD</h1></body>", b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator("h1").inner_text() == "OLD"
|
|
assert page.locator(".booth-nav-home").count() == 1
|
|
page.close()
|
|
|
|
|
|
def test_a_page_that_declares_its_own_icon_keeps_it(browser, live):
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
page = _open(
|
|
browser, base, "b",
|
|
f'<!doctype html><head><link rel="icon" href="data:image/png;base64,AAAA">'
|
|
f"</head><body>x{SEAM}</body>", b)
|
|
page.wait_for_selector(".booth-nav-home")
|
|
icons = page.locator('link[rel="icon"]')
|
|
assert icons.count() == 1
|
|
assert icons.get_attribute("href").startswith("data:image/png")
|
|
page.close()
|
|
|
|
|
|
# ---- placement ---------------------------------------------------------------
|
|
|
|
|
|
REPORT = f"""<!doctype html><title>audition</title><body>
|
|
<h1>Three voices</h1>
|
|
<section id="lawson"><audio src="a.wav"></audio>
|
|
<div data-booth-ask="batch:r1"></div></section>
|
|
<section id="jo"><audio src="b.wav"></audio>
|
|
<div data-booth-mark="batch:r2"></div></section>
|
|
<div data-booth-ask-submit="batch"></div>
|
|
{SEAM}
|
|
</body>"""
|
|
|
|
|
|
def test_each_question_lands_where_the_author_put_it(browser, live):
|
|
"""The 2026-09-09 ruling, enforced in the DOM: the question for a voice sits
|
|
under that voice, not on another page and not in a pile at the end. Both
|
|
attribute spellings, because live reports use the older one."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", REPORT, b)
|
|
page.wait_for_selector("#lawson .bk-ask")
|
|
assert page.locator('#lawson input[name="choice.r1"]').count() == 2
|
|
assert page.locator('#jo input[name="choice.r2"]').count() == 2
|
|
# nothing spilled to the end of the body: every piece had an anchor
|
|
assert page.locator("body > .bk-ask").count() == 0
|
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
|
page.close()
|
|
|
|
|
|
def test_the_authors_wrapper_and_its_contents_survive_the_mount(browser, live):
|
|
"""The live `dfa-concepts` shape — a non-empty styled wrapper carrying the
|
|
anchor attribute. The regex this replaced matched the opening tag and
|
|
SUBSTITUTED it, eating the class and orphaning the heading. beforeend keeps
|
|
both and puts the radios under the heading, which is what the markup says."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body><div class="ask" data-booth-ask="batch:r1">'
|
|
f"<h3>The one asset that must survive</h3></div>{SEAM}</body>"), b)
|
|
page.wait_for_selector(".ask .bk-ask")
|
|
assert page.locator("div.ask").count() == 1 # class kept
|
|
assert page.locator(".ask h3").inner_text() == "The one asset that must survive"
|
|
assert page.locator('.ask input[name="choice.r1"]').count() == 2 # radios inside
|
|
page.close()
|
|
|
|
|
|
def test_an_unplaced_question_is_appended_and_so_is_its_submit(browser, live):
|
|
"""INV-7. A multi-question pick needs EVERY question on submit or the POST is
|
|
unanswerable: a question that never reaches the page cannot be picked, and
|
|
a submission with nothing picked at all is refused outright."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b",
|
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
|
# attached, not visible: the shared <form> is deliberately empty and so has
|
|
# no box — the controls that bind to it are what the operator sees.
|
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
|
# BOTH halves. Asserting only the appended r2 let a mutation that silently
|
|
# swallowed the anchored r1 — while still recording it as placed — pass.
|
|
assert page.locator('input[name="choice.r1"]').count() == 2 # anchored, mounted
|
|
assert page.locator('input[name="choice.r2"]').count() == 2 # unplaced, appended
|
|
assert page.locator("form#bk-ask-form-batch").count() == 1 # submittable
|
|
page.close()
|
|
|
|
|
|
def test_a_page_with_no_anchors_gets_the_whole_ask(browser, live):
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", f"<!doctype html><body><p>x</p>{SEAM}</body>", b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
|
page.close()
|
|
|
|
|
|
def test_an_anchor_naming_no_mark_is_left_alone(browser, live):
|
|
"""A typo'd id stays visible as the author's own empty element rather than
|
|
being blanked — and the real ask is still never lost."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b",
|
|
f'<!doctype html><body><div id="t" data-booth-mark="typo"></div>{SEAM}</body>', b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator("#t").inner_html().strip() == ""
|
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
|
page.close()
|
|
|
|
|
|
def test_the_tail_follows_payload_order(browser, live):
|
|
"""INV-6. Creation order and alphabetical order DISAGREE here on purpose:
|
|
`winner` is created first, `batch` second, so payload order is
|
|
winner-then-batch while an id sort would give the reverse. The first version
|
|
of this fixture made the two identical, so sorting the tail alphabetically
|
|
in JavaScript passed it — caught by a panel reading the fixture."""
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
_multi(b)
|
|
raw = json.loads((b / ".marks.json").read_text())
|
|
stamps = {"winner": "2026-09-22T10:00:00.000000-07:00", # first
|
|
"batch": "2026-09-22T11:00:00.000000-07:00"} # second
|
|
for e in raw["marks"]:
|
|
e["created"] = stamps[e["id"]]
|
|
(b / ".marks.json").write_text(json.dumps(raw))
|
|
page = _open(browser, base, "b", f"<!doctype html><body>{SEAM}</body>", b)
|
|
page.wait_for_selector(".bk-ask")
|
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
|
batch = [i for i, v in enumerate(ids) if "batch" in v]
|
|
winner = [i for i, v in enumerate(ids) if "winner" in v]
|
|
assert batch and winner, ids
|
|
# winner (created first) ahead of batch (created second) — the OPPOSITE of
|
|
# alphabetical, so an id sort cannot pass this.
|
|
assert max(winner) < min(batch), ids
|
|
page.close()
|
|
|
|
|
|
# ---- the one that actually matters ------------------------------------------
|
|
|
|
|
|
def test_a_form_scattered_down_the_report_submits_every_question(browser, live):
|
|
"""THE load-bearing browser test.
|
|
|
|
Four radio groups under four different artifacts, one <form> somewhere else
|
|
entirely, bound only by the HTML5 `form=` attribute — and now inserted into
|
|
a live document in visual order, which means a control can land before the
|
|
form it points at. If form-owner resolution does not survive that, the
|
|
operator fills the whole thing in and the button saves nothing.
|
|
|
|
It was true by construction before U3 (static HTML, resolved at parse). It
|
|
is true by measurement now. That is the trade this test pays for.
|
|
"""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", REPORT, b)
|
|
page.wait_for_selector("#lawson .bk-ask")
|
|
page.check('#lawson input[name="choice.r1"][value="keep"]')
|
|
page.check('#jo input[name="choice.r2"][value="cut"]')
|
|
with page.expect_navigation():
|
|
page.click("button.bk-ask-go")
|
|
ans = _answer(b)
|
|
assert ans is not None, "the scattered form submitted nothing"
|
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
|
assert ans["answers"]["r2"]["choice"] == "cut", \
|
|
"a question bound by form= did not reach the POST"
|
|
assert ans["complete"] is True
|
|
page.close()
|
|
|
|
|
|
def test_the_chip_jumps_to_the_first_fragment_of_the_open_ask(browser, live):
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", REPORT, b)
|
|
page.wait_for_selector(".booth-nav-asks")
|
|
chip = page.locator(".booth-nav-asks")
|
|
assert chip.inner_text() == "? 1 open ask"
|
|
target = chip.get_attribute("href")
|
|
# the EARLIEST match in document order, not merely a match: the report
|
|
# anchors r1 above r2 above the submit block, so any later one is wrong.
|
|
ids = page.eval_on_selector_all("[id^='bk-ask-batch']", "els => els.map(e => e.id)")
|
|
assert ids, "no batch fragment mounted"
|
|
assert target == "#" + ids[0], (target, ids)
|
|
page.close()
|
|
|
|
|
|
def test_an_anchor_named_like_an_object_property_does_not_kill_the_page(browser, live):
|
|
"""`toString` is a legal mark id (`asks.valid_stem`) and therefore a legal
|
|
thing for an author to typo into an anchor. Against a plain `{}` lookup it
|
|
came back as Object.prototype.toString — truthy, so it sailed past the
|
|
unknown-mark guard and threw on `.questions.length`, aborting placement
|
|
before the tail and costing the page EVERY ask. One typo, no chrome, no
|
|
error the operator would see. Found by a cross-frontier panel."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body><div id="t" data-booth-mark="toString"></div>'
|
|
f'<div id="v" data-booth-mark="valueOf:r1"></div>{SEAM}</body>'), b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator("#t").inner_html().strip() == "" # left alone
|
|
assert page.locator("#v").inner_html().strip() == "" # left alone
|
|
# and the real ask still mounted, which is what the bug destroyed
|
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
|
assert page.locator('input[name="choice.r2"]').count() == 2
|
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
|
page.close()
|
|
|
|
|
|
def test_a_question_keyed_like_an_object_property_is_not_swallowed(browser, live):
|
|
"""The mirror of the same bug, on the `placed` set. `constructor` matches
|
|
`asks._KEY_RE`, and against a plain object an inherited `got.constructor`
|
|
read as ALREADY PLACED — so a question the author did not anchor was
|
|
silently dropped from the tail, which is INV-7's whole subject."""
|
|
from booth.marks import declare_pick
|
|
|
|
base, data = live
|
|
b = data / "b"
|
|
b.mkdir(parents=True, exist_ok=True)
|
|
declare_pick(b, "batch", {"title": "Round one", "questions": [
|
|
{"key": "r1", "prompt": "First?", "options": ["keep", "cut"]},
|
|
{"key": "constructor", "prompt": "Second?", "options": ["keep", "cut"]},
|
|
]})
|
|
page = _open(browser, base, "b",
|
|
f'<!doctype html><body><div data-booth-mark="batch:r1"></div>{SEAM}</body>', b)
|
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
|
assert page.locator('input[name="choice.constructor"]').count() == 2, \
|
|
"an unplaced question was swallowed by an inherited property"
|
|
page.close()
|
|
|
|
|
|
def test_a_submit_anchor_inside_the_authors_own_form_still_submits(browser, live):
|
|
"""A submit anchor placed inside the author's own `<form>` loses ours: the
|
|
HTML parser drops a nested form element outright. The controls' `form=`
|
|
then points at nothing, the button does nothing, and the operator finds out
|
|
by filling the whole thing in. Found by a cross-frontier bug-hunt panel; the
|
|
fix is to count the anchor submitted only if the form actually survived, so
|
|
the tail supplies one at body level where no form encloses it."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body>'
|
|
'<div data-booth-mark="batch:r1"></div>'
|
|
'<div data-booth-mark="batch:r2"></div>'
|
|
'<form id="mine" action="/elsewhere">'
|
|
'<div data-booth-ask-submit="batch"></div></form>'
|
|
f"{SEAM}</body>"), b)
|
|
page.wait_for_selector("form#bk-ask-form-batch", state="attached")
|
|
assert page.locator("form#bk-ask-form-batch").count() == 1
|
|
page.check('input[name="choice.r1"][value="keep"]')
|
|
page.check('input[name="choice.r2"][value="cut"]')
|
|
with page.expect_navigation():
|
|
page.click("button.bk-ask-go")
|
|
ans = _answer(b)
|
|
assert ans is not None, "the button reached no form"
|
|
assert ans["answers"]["r1"]["choice"] == "keep"
|
|
assert ans["answers"]["r2"]["choice"] == "cut"
|
|
page.close()
|
|
|
|
|
|
def test_a_broken_pick_shows_its_diagnostic_even_from_a_submit_anchor(browser, live):
|
|
"""A broken pick has no submit block — its `submit` is empty and the
|
|
diagnostic lives in `whole`. Mounting that empty string and then recording
|
|
the pick as placed made the tail skip it, so the 'broken ask' box never
|
|
rendered at the one surface built to show it. A question the session
|
|
believes it posted has to be visible."""
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
raw = json.loads((b / ".marks.json").read_text())
|
|
raw["marks"][0]["declaration"] = {"prompt": "p"} # no options -> AskError
|
|
(b / ".marks.json").write_text(json.dumps(raw))
|
|
page = _open(browser, base, "b",
|
|
f'<!doctype html><body><div id="s" data-booth-ask-submit="winner">'
|
|
f"</div>{SEAM}</body>", b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator("#s").inner_html().strip() == "" # anchor left alone
|
|
# inner_text() is the RENDERED text, and `.bk-ask-tag` is uppercased by CSS —
|
|
# so assert the diagnostic itself, which is the part that has to reach him.
|
|
shown = page.locator("body").inner_text()
|
|
assert "this question could not be read" in shown, shown
|
|
assert "options" in shown # the actual reason
|
|
page.close()
|
|
|
|
|
|
def test_an_author_element_cannot_hijack_the_chip(browser, live):
|
|
"""`<section id="bk-ask-winner-background">` satisfies any id-prefix rule,
|
|
hyphen boundary included. The chip therefore searches only the elements THIS
|
|
SCRIPT MOUNTED — the identity the deleted `bk-ask-<id>-top` anchor used to
|
|
guarantee — and takes the earliest of those in document order."""
|
|
base, data = live
|
|
b = _single(data / "b")
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body><section id="bk-ask-winner-background">notes</section>'
|
|
f"<p>report</p>{SEAM}</body>"), b)
|
|
page.wait_for_selector(".booth-nav-asks")
|
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
|
assert target != "#bk-ask-winner-background"
|
|
landed = page.locator(target)
|
|
assert landed.count() == 1
|
|
assert landed.evaluate("e => e.classList.contains('bk-ask')"), \
|
|
"the chip jumped to something the Booth did not mount"
|
|
page.close()
|
|
|
|
|
|
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
|
"""A SIBLING MARK's fragment must not take the chip, even when it is earlier
|
|
in the document. `batch2`'s id starts with `batch`, so the original
|
|
id-prefix rule could land on it; the mounted-elements rule cannot, because
|
|
the candidates are partitioned by mark.
|
|
|
|
⚠ The first version of this test put the sibling's fragment AFTER the open
|
|
mark's, so the right answer was also the first answer and pooling every
|
|
mark's elements passed it. The vacuity pass caught that; the fixture now
|
|
puts the sibling FIRST, which is the only arrangement that can tell the two
|
|
implementations apart.
|
|
"""
|
|
from booth.marks import declare_pick
|
|
|
|
base, data = live
|
|
b = _multi(data / "b") # `batch`, created first
|
|
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
|
|
|
# batch2 is anchored at the very top; batch is unanchored and so lands in
|
|
# the tail, at the END of the body. Document order is therefore batch2's
|
|
# fragments, then batch's.
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body><div data-booth-mark="batch2"></div>'
|
|
f"<p>report</p>{SEAM}</body>"), b)
|
|
page.wait_for_selector(".booth-nav-asks")
|
|
|
|
ids = page.eval_on_selector_all("[id^='bk-ask-']", "els => els.map(e => e.id)")
|
|
assert any("batch2" in i for i in ids) and any(
|
|
"batch2" not in i and "batch" in i for i in ids), ids
|
|
assert ids.index(next(i for i in ids if "batch2" in i)) < \
|
|
ids.index(next(i for i in ids if "batch2" not in i and "batch" in i)), \
|
|
f"fixture is wrong: the sibling must come FIRST, got {ids}"
|
|
|
|
# `open` is (created, id) -> batch before batch2, so the chip targets batch
|
|
target = page.locator(".booth-nav-asks").get_attribute("href")
|
|
assert "batch2" not in target, f"the chip landed on the sibling mark: {target}"
|
|
assert target.startswith("#bk-ask-batch")
|
|
assert page.locator(target).count() == 1
|
|
page.close()
|
|
|
|
|
|
def test_the_canonical_attribute_wins_when_both_are_present(browser, live):
|
|
"""`data-booth-mark` is canonical and `data-booth-ask` is the kept alias.
|
|
An element carrying both is not a case any live report has, but the
|
|
precedence has to be decided somewhere rather than by selector order."""
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
page = _open(browser, base, "b", (
|
|
'<!doctype html><body><div id="a" data-booth-mark="batch:r2" '
|
|
f'data-booth-ask="batch:r1"></div>{SEAM}</body>'), b)
|
|
page.wait_for_selector("#a .bk-ask")
|
|
assert page.locator('#a input[name="choice.r2"]').count() == 2 # canonical
|
|
assert page.locator('#a input[name="choice.r1"]').count() == 0 # alias ignored
|
|
# r1 was never placed, so INV-7 still puts it somewhere
|
|
assert page.locator('input[name="choice.r1"]').count() == 2
|
|
page.close()
|
|
|
|
|
|
def test_the_chip_count_comes_from_the_server(browser, live):
|
|
"""INV-4. A half-answered multi-question pick is STILL OPEN, and the page
|
|
does not get to have an opinion about that — `open_marks` decides."""
|
|
from booth.marks import answer_pick
|
|
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
answer_pick(b, "batch", {"r1": "keep"})
|
|
page = _open(browser, base, "b", REPORT, b)
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator(".booth-nav-asks").count() == 1
|
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"})
|
|
page.reload(wait_until="networkidle")
|
|
page.wait_for_selector(".bk-ask")
|
|
assert page.locator(".booth-nav-asks").count() == 0
|
|
page.close()
|
|
|
|
|
|
def test_the_chip_follows_a_payload_that_disagrees_with_the_fragments(browser, live):
|
|
"""INV-4, and the version that actually falsifies it.
|
|
|
|
The test above uses honest fixtures, so a client that INFERRED openness from
|
|
the rendered fragments would pass it — the fragments and `open` always agree
|
|
when the server computes both. A panel pointed out that this never creates
|
|
the disagreement it claims to test.
|
|
|
|
So: intercept the response and make `open` lie. The fragments say fully
|
|
answered; the payload says two are open. The chip must follow the payload,
|
|
because the payload is the only thing that decides.
|
|
"""
|
|
import json as _json
|
|
|
|
from booth.marks import answer_pick
|
|
|
|
base, data = live
|
|
b = _multi(data / "b")
|
|
answer_pick(b, "batch", {"r1": "keep", "r2": "cut"}) # nothing is open
|
|
(b / "index.html").write_text(f"<!doctype html><body>{SEAM}</body>", encoding="utf-8")
|
|
|
|
def lie(route):
|
|
body = _json.loads(route.fetch().text())
|
|
assert body["open"] == [], "fixture is not answered; the lie would be true"
|
|
body["open"] = ["batch", "batch"]
|
|
route.fulfill(status=200, content_type="application/json",
|
|
body=_json.dumps(body))
|
|
|
|
page = browser.new_page()
|
|
page.route("**/embed.json", lie)
|
|
page.goto(f"{base}/b/b/", wait_until="networkidle")
|
|
page.wait_for_selector(".booth-nav-asks")
|
|
assert page.locator(".booth-nav-asks").inner_text() == "? 2 open asks"
|
|
page.close()
|
|
|
|
|
|
# --- the grid keyboard, which is the OTHER thing no string assertion sees ----
|
|
# Added 2026-09-22 after the heid bug-hunt panel found a defect whose entire
|
|
# expression is viewport geometry: a group jump moves the scroll position, the
|
|
# keyboard cursor does not know, and the next arrow key scrolls back.
|
|
|
|
|
|
def _gallery(root, name="g"):
|
|
"""Enough tiles that the grid must scroll, in two groups."""
|
|
b = root / name
|
|
b.mkdir()
|
|
for i in range(1, 13):
|
|
(b / f"aa{i:02d}.png").write_bytes(PNG)
|
|
for i in range(1, 13):
|
|
(b / f"zz{i:02d}.png").write_bytes(PNG)
|
|
return b
|
|
|
|
|
|
def test_an_arrow_after_a_group_jump_does_not_scroll_back(browser, live):
|
|
"""GRÓA's solo. The jump scrolled the viewport but left the cursor at -1,
|
|
so the next ArrowRight focused tile 0 and `scrollIntoView` yanked the page
|
|
back to the top — silently reversing the jump the operator just made.
|
|
|
|
The whole failure is geometry, so it is asserted on geometry: scroll
|
|
position after the arrow must stay near where the jump landed, not return
|
|
to the top. Defeating change: `focus(at + 1)` with `at` starting at -1."""
|
|
base, root = live
|
|
_gallery(root)
|
|
page = browser.new_page()
|
|
page.set_viewport_size({"width": 900, "height": 600})
|
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
|
|
|
page.click('.rail-g[data-group="zz"]')
|
|
page.wait_for_timeout(250)
|
|
after_jump = page.evaluate("window.scrollY")
|
|
assert after_jump > 0, "the group jump did not scroll at all"
|
|
|
|
page.keyboard.press("ArrowRight")
|
|
page.wait_for_timeout(250)
|
|
after_key = page.evaluate("window.scrollY")
|
|
page.close()
|
|
|
|
assert after_key > after_jump / 2, (
|
|
f"the arrow key undid the jump: scrollY {after_jump} -> {after_key}"
|
|
)
|
|
|
|
|
|
def test_the_keyboard_flag_actually_submits(browser, live):
|
|
"""HULDA's solo. `f` selected `.flagbtn, [name="target"]`; nothing in this
|
|
repo emits `.flagbtn`, so it clicked the HIDDEN target input — and clicking
|
|
a hidden input does not submit its form. The shortcut never worked while
|
|
still swallowing the keystroke.
|
|
|
|
Asserted end to end: press f, and the flag must come back from the server
|
|
on the reloaded page."""
|
|
base, root = live
|
|
_gallery(root)
|
|
page = browser.new_page()
|
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
|
|
|
page.keyboard.press("ArrowRight") # cursor onto the first tile
|
|
with page.expect_navigation(): # the flag form POSTs and redirects back
|
|
page.keyboard.press("f")
|
|
flagged = page.locator("figure.item.is-flagged").count()
|
|
page.close()
|
|
|
|
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
|