The operator: "I think I want creation and update dates on the booths now too." UPDATE was already there — `landed_at`, the newest mtime among CONTENT excluding our own machinery, which the Desk already sorts "new since you looked" by. CREATION had no honest source. `.booth.json` carries a declared `created`, but only for booths posted through the CLI since U5 — TWELVE OF THIRTY live booths had none. Every alternative was a guess wearing a fact's clothes: oldest content mtime is wrong the moment an agent copies files with timestamps preserved; directory mtime is just "last thing added", which is landed_at renamed; and stamping a first-seen marker on read is the same write-on-read shape that spent an hour of today aging the booth it cached. ext4 records a real birth time. CPython does not expose st_birthtime on Linux, so booth/birthtime.py reads it through statx(2) — a fact the disk already holds rather than one we invent. Verified against stat(1) on live booths, 6 of 6 exact, including every booth with no manifest. ONE rule for all thirty, which is what invariant 6 asks of anything statable in a line. None when the filesystem cannot say (tmpfs, NFS, an old kernel), and None renders as nothing — the honest output when nobody knows. Never raises: list_booths calls it once per booth on every index load, so a read that can raise is a service-wide outage wearing a single-booth bug's clothes. ALSO TWO REAL TEST-HARNESS DEFECTS, found chasing a flake and fixed on their merits rather than because they were proven to be the cause: - The keyboard-flag browser test fired ArrowRight and `f` back to back, assuming the first had finished — and focus() does a scrollIntoView, so under load `f` could arrive with no cursor and flag nothing. It now waits for the cursor to land. - BOTH browser fixtures did bind -> getsockname -> CLOSE -> hand uvicorn the port NUMBER, leaving a window for the kernel to give that port to somebody else. This suite runs two browser files that each start a server per test, so the competitor is right there. The bound socket is now handed over directly. ⚠ THE FLAKE IS NOT PROVEN FIXED. Two different browser tests failed once each across full-suite runs while passing 3/3 and 5/5 in isolation; since the fixes, one failure in three runs. n=3 cannot distinguish that from the prior rate and this commit does not claim it does. 770 green on a clean run.
641 lines
28 KiB
Python
641 lines
28 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]
|
|
# ⚠ THE SOCKET IS HANDED TO UVICORN STILL BOUND, never closed and
|
|
# re-opened by port number. The old form did bind -> getsockname -> CLOSE ->
|
|
# tell uvicorn the number, which leaves a window where the kernel can give
|
|
# that port to somebody else — and this suite runs TWO browser files that
|
|
# each start a server per test, so the other one is right there competing
|
|
# for it. Passing the live socket removes the window rather than narrowing
|
|
# it.
|
|
#
|
|
# Honest about the evidence: two different browser tests failed once each
|
|
# across full-suite runs while passing 3/3 and 5/5 on their own, which is
|
|
# the signature of contention. We cannot prove from two samples that this
|
|
# race was the cause. It is a real defect either way, and it is the only
|
|
# one visible in the harness.
|
|
|
|
|
|
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=lambda: server.run(sockets=[sock]), 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.
|
|
|
|
R2 C3 (docs/contracts/r2_flow.contract.md, "Assertions that change"): this
|
|
used to expect a NAVIGATION — the flag form POSTed, 303'd and reloaded. That
|
|
was the no-JS design working, not a defect, and it still is with scripts
|
|
off (tests/golden/r2_mark_303.json replays those responses byte for byte).
|
|
What changed is that WITH JS ON the flag now applies in place. The claim
|
|
that matters is kept and tightened: the flag must come back from the SERVER
|
|
(the swapped tile is server-rendered), and a marker set on the window before
|
|
the keypress must survive, which a reload would wipe."""
|
|
base, root = live
|
|
_gallery(root)
|
|
page = browser.new_page()
|
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
|
page.evaluate("window.__noReload = 1")
|
|
|
|
page.keyboard.press("ArrowRight")
|
|
# ⚠ WAIT FOR THE CURSOR TO LAND BEFORE PRESSING `f`. Firing both keys
|
|
# back to back assumed the first had finished, and `focus()` does a
|
|
# `scrollIntoView` — so under full-suite load `f` could arrive with no
|
|
# cursor set and flag nothing. It failed once in roughly five whole-suite
|
|
# runs while passing 3/3 on its own, which is the signature of a race
|
|
# rather than a defect, and a test that goes red one time in five trains
|
|
# people to ignore red.
|
|
page.wait_for_selector("figure.item.is-cursor", timeout=10000)
|
|
page.keyboard.press("f")
|
|
page.wait_for_selector("figure.item.is-flagged", timeout=10000)
|
|
flagged = page.locator("figure.item.is-flagged").count()
|
|
survived = page.evaluate("window.__noReload === 1")
|
|
page.close()
|
|
|
|
assert flagged == 1, f"the f key flagged {flagged} items, expected 1"
|
|
assert survived, "the flag reloaded the page; in-place judgment must not"
|