feat(u3): a verbatim report declares the seam, the Booth mounts into it
A booth that ships its own index.html was served through ten regular
expressions applied to markup the Booth did not write: six in
wrap_verbatim_html hunting for somewhere to hang a favicon and a chip, four
in booth/inline.py substituting rendered ask markup into the author's own
tags. Both worked. Both were the most fragile thing in the service, on the
path the operator uses most.
The whole class is replaced by a declared seam. A report carries one line —
<script src="/_booth/embed.js" defer></script> — and the chrome mounts
through DOM APIs. What the server does to author HTML is now, in full:
return html if declares_embed(html) else html + EMBED_SCRIPT_TAG
Two substring tests and a concatenation. Both of the old wrapper's hard
constraints stop existing rather than being satisfied more carefully:
nothing can displace a leading doctype into quirks mode and nothing can push
the charset meta out of its detection window, because nothing in front of
them ever moves. A page that declares the seam is served exactly as written.
Fragments are still rendered by the _ask_inline.html macros and handed over
GET /b/<name>/embed.json; embed.js places them and decides nothing. Openness
comes from open_marks, order from (created, id), questions in declaration
order. A single-question pick normalizes to key None, so the payload carries
questions as a list rather than an object — keying by name would serialize
that as the string "null".
Placement is an anchor fill, not a replacement: el.insertAdjacentHTML(
'beforeend'), so an author's wrapper and its contents survive. The regex it
replaces was eating the opening tag of dfa-concepts' styled .ask blocks and
orphaning their headings, live, unreported.
data-booth-mark is canonical; data-booth-ask stays a kept alias because two
live reports use it. The comment placeholders are dropped — no users.
Declared cost: the verbatim path now needs JavaScript. The never-invisible
guarantee holds through the index badge and /b/<name>/marks, both of which
render server-side.
Deleted: booth/inline.py entire, wrap_verbatim_html and its six patterns,
_BACK_CHIP, asks_chip, inject_asks, FAVICON_LINK, the styles() macro.
Tests 410 -> 434. tests/test_embed_browser.py drives a real Chromium: the
placement algorithm and the form= binding of a scattered multi-question form
cannot be observed any other way, and that binding was measured rather than
assumed (N=3 per condition, with a form-first positive control and a
points-at-nothing negative control).
Contract: docs/contracts/u3_declared_embed_seam.contract.md, with the
in-session seam review and the cold contract panel both recorded. Two of the
panel's findings were code fixes: a vacuous INV-3 falsifier that a renamed
regex walked straight through, and a bare-substring seam detection that read
a report merely quoting the path as declaring it and silently served it with
no chrome.
This commit is contained in:
+71
-67
@@ -13,7 +13,7 @@ import pathlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import build_gallery, create_app, list_booths
|
||||
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
|
||||
from booth.asks import (
|
||||
ANSWER_SUFFIX,
|
||||
ASK_SUFFIX,
|
||||
@@ -410,32 +410,37 @@ def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
|
||||
# into the verbatim page plus a standalone /asks page that carries the forms.
|
||||
|
||||
|
||||
def test_verbatim_booth_renders_the_ask_inline(client):
|
||||
def test_verbatim_booth_offers_the_ask_over_the_seam(client):
|
||||
"""U3: the report is served as written and the ask crosses the declared seam.
|
||||
Before U3 the fragments were substituted into the page body by regex; the
|
||||
guarantee that the ask is reachable FROM THE REPORT, not from another page,
|
||||
is unchanged — it is the delivery that moved."""
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
||||
html = c.get("/b/b/").text
|
||||
assert "hi" in html # the report is still served verbatim
|
||||
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
||||
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
||||
assert "bk-ask" in html # self-contained fragment styles
|
||||
assert "booth-nav-asks" in html # chip remains, as a jump link
|
||||
assert "#bk-ask-winner-top" in html
|
||||
assert "Which render wins?" not in html # ...and NOTHING was injected into it
|
||||
assert html.endswith(EMBED_SCRIPT_TAG)
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "Which render wins?" in m["whole"]
|
||||
assert 'type="radio"' in m["whole"] and 'action="/b/b/answer"' in m["submit"]
|
||||
|
||||
|
||||
def test_verbatim_chip_disappears_once_answered(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["winner"]
|
||||
answer_pick(b, "winner", "A — baseline")
|
||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
||||
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||
|
||||
|
||||
def test_verbatim_booth_without_asks_is_untouched(client):
|
||||
c, data = client
|
||||
(data / "b").mkdir()
|
||||
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
||||
assert "booth-nav-asks" not in c.get("/b/b/").text
|
||||
assert c.get("/b/b/embed.json").json()["marks"] == []
|
||||
|
||||
|
||||
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
||||
@@ -477,42 +482,64 @@ def test_asks_page_shows_a_single_ask_title(client):
|
||||
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
|
||||
|
||||
|
||||
# ---- inline placement in a verbatim report -----------------------------------
|
||||
# ---- placement in a verbatim report ------------------------------------------
|
||||
#
|
||||
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
||||
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
||||
# each voice's radio group under that voice's audio, and one submit for the lot.
|
||||
|
||||
#
|
||||
# U3 kept the semantics and moved the mechanism. The author still marks up where
|
||||
# each piece goes; the pieces are still rendered by the `_ask_inline.html`
|
||||
# macros; they now reach the page through `/b/<name>/embed.json` and are mounted
|
||||
# by `/_booth/embed.js` instead of substituted into the author's tags by regex.
|
||||
#
|
||||
# So the placement ASSERTIONS moved too, and where each half lives is not
|
||||
# arbitrary: what the server offers is checked here, in Python; where it LANDS,
|
||||
# and whether a form scattered down a report actually submits, is checked in
|
||||
# tests/test_embed_browser.py against a real DOM. No string assertion can see
|
||||
# the second thing, and that is exactly the part the operator depends on.
|
||||
|
||||
REPORT = """<!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>
|
||||
<!-- booth:ask batch:r2 --></section>
|
||||
<div data-booth-mark="batch:r2"></div></section>
|
||||
<div data-booth-ask-submit="batch"></div>
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
</body>"""
|
||||
|
||||
|
||||
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
||||
def test_the_author_markup_is_never_touched_by_the_server(client):
|
||||
"""The whole point of the seam. A page that declares it comes back exactly
|
||||
as written — placeholders still empty, waiting for the DOM."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
html = c.get("/b/b/").text
|
||||
# each group is inside its own section, in document order
|
||||
lawson = html.index('id="lawson"')
|
||||
jo = html.index('id="jo"')
|
||||
assert lawson < html.index('name="choice.r1"') < jo
|
||||
assert jo < html.index('name="choice.r2"')
|
||||
# one shared form, bound by the HTML5 form= attribute, submitted once
|
||||
assert html.count('<form id="bk-ask-form-batch"') == 1
|
||||
assert html.count('action="/b/b/answer"') == 1
|
||||
assert html.count('form="bk-ask-form-batch"') >= 4
|
||||
# the submit block landed at its own placeholder, not appended after </body>
|
||||
assert html.index("bk-ask-form-batch") < html.index("</body>")
|
||||
assert c.get("/b/b/").text == REPORT
|
||||
|
||||
|
||||
def test_inline_form_submits_every_question_in_one_post(client):
|
||||
def test_every_piece_the_author_can_place_is_offered(client):
|
||||
"""One fragment per addressable piece: the whole ask, each question, and the
|
||||
submit block that carries the shared <form>. The author's markup decides
|
||||
which are used; the payload never decides for them."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert [q["key"] for q in m["questions"]] == ["r1", "r2"]
|
||||
assert 'name="choice.r1"' in m["questions"][0]["html"]
|
||||
assert 'name="choice.r2"' in m["questions"][1]["html"]
|
||||
# ONE form, and it lives with the submit block, so question groups scattered
|
||||
# down a report bind to it by id from wherever they sit.
|
||||
assert m["submit"].count('<form id="bk-ask-form-batch"') == 1
|
||||
assert m["submit"].count('action="/b/b/answer"') == 1
|
||||
assert 'form="bk-ask-form-batch"' in m["questions"][0]["html"]
|
||||
assert 'form="bk-ask-form-batch"' in m["questions"][1]["html"]
|
||||
|
||||
|
||||
def test_a_scattered_form_still_posts_as_one_answer(client):
|
||||
"""The POST half of the multi-question guarantee, which U3 did not touch:
|
||||
every question in one request, or the route refuses it."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
@@ -521,44 +548,21 @@ def test_inline_form_submits_every_question_in_one_post(client):
|
||||
assert r.status_code == 303
|
||||
ans = _answer_of(b, "batch")
|
||||
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
||||
# and the recorded pick now shows inline, on the report itself
|
||||
html = c.get("/b/b/").text
|
||||
assert "recorded:" in html and "bk-done" in html
|
||||
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
||||
# and the recorded pick comes back marked answered, on the report's own seam
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "recorded:" in m["whole"] and "bk-done" in m["whole"]
|
||||
assert "checked" in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_whole_ask_placeholder_renders_everything_there(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert html.index("Which render wins?") > html.index("<p>x</p>")
|
||||
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
|
||||
|
||||
|
||||
def test_placeholder_for_a_missing_ask_is_left_alone(client):
|
||||
c, data = client
|
||||
b = _ask(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
|
||||
assert "Which render wins?" in html # the real ask still appended, never lost
|
||||
|
||||
|
||||
def test_questions_placed_without_a_submit_still_get_one(client):
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
|
||||
html = c.get("/b/b/").text
|
||||
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
|
||||
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
|
||||
|
||||
|
||||
def test_styles_are_emitted_once(client):
|
||||
def test_the_page_carries_no_fragment_styles(client):
|
||||
"""`styles()` is gone from the template: the scoped `.bk-ask-*` rules live in
|
||||
embed.js, next to the code that mounts them. One asset, emitted once by
|
||||
construction rather than by a seen-set."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text(REPORT)
|
||||
assert c.get("/b/b/").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||
assert ".bk-ask-opt:has(input:checked)" not in c.get("/b/b/").text
|
||||
assert c.get("/_booth/embed.js").text.count(".bk-ask-opt:has(input:checked)") == 1
|
||||
|
||||
|
||||
def test_radios_are_not_html_required_anywhere(client):
|
||||
@@ -566,20 +570,20 @@ def test_radios_are_not_html_required_anywhere(client):
|
||||
is exactly what stopped the operator leaving one blank."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
assert "required" not in c.get("/b/b/").text
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
||||
assert "required" not in c.get("/b/b/").text
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "required" not in m["whole"]
|
||||
assert not any("required" in q["html"] for q in m["questions"])
|
||||
assert "required" not in c.get("/b/b/marks").text
|
||||
|
||||
|
||||
def test_partial_answer_renders_as_skipped_inline(client):
|
||||
def test_partial_answer_renders_as_skipped(client):
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch"></div></body>')
|
||||
(b / "index.html").write_text(REPORT)
|
||||
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
|
||||
html = c.get("/b/b/").text
|
||||
assert "bk-skip" in html and "left blank" in html
|
||||
assert "1 of 2 answered" in html
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert "bk-skip" in m["whole"] and "left blank" in m["whole"]
|
||||
assert "1 of 2 answered" in m["submit"]
|
||||
|
||||
|
||||
def test_empty_submission_is_refused_with_400(client):
|
||||
|
||||
+17
-57
@@ -16,7 +16,7 @@ from booth.app import (
|
||||
remove_link_entry,
|
||||
toggle_pin,
|
||||
booth_age_seconds,
|
||||
FAVICON_LINK,
|
||||
EMBED_SCRIPT_TAG,
|
||||
KEEP_MARKER,
|
||||
build_gallery,
|
||||
classify,
|
||||
@@ -30,7 +30,6 @@ from booth.app import (
|
||||
render_doc,
|
||||
safe_upload_name,
|
||||
sweep_once,
|
||||
wrap_verbatim_html,
|
||||
)
|
||||
|
||||
PICKUP_RE = re.compile(r"^(\d{1,2}-[a-z]+|[a-z]+-\d{1,2})$")
|
||||
@@ -459,58 +458,21 @@ def test_view_nonviewable_redirects_to_raw(client):
|
||||
assert r.headers["location"] == "/b/run1/data.bin"
|
||||
|
||||
|
||||
# ---- verbatim-index.html wrapper --------------------------------------------
|
||||
# ---- verbatim-index.html serving -------------------------------------------
|
||||
#
|
||||
# U3 replaced the injection wrapper with a declared seam. The five `test_wrap_*`
|
||||
# tests and `test_verbatim_booth_wrapped_with_back_chip` that stood here tested
|
||||
# `wrap_verbatim_html` — six regexes hunting a head-ish seam for a favicon and a
|
||||
# body-ish seam for a chip, plus the doctype and charset-window constraints they
|
||||
# threaded. None of those constraints can be violated by an append, so there is
|
||||
# nothing left of them to assert. What replaced them lives in tests/test_embed.py
|
||||
# (the payload, the one appended tag, whole-body equality for a declaring page)
|
||||
# and tests/test_embed_browser.py (the mount, in a real DOM).
|
||||
#
|
||||
# What stays here is what did NOT change: the file route is still raw.
|
||||
|
||||
|
||||
def test_wrap_injects_chip_and_favicon():
|
||||
html = "<html><head><title>Brief</title></head><body><h1>REPORT</h1></body></html>"
|
||||
out = wrap_verbatim_html(html)
|
||||
assert 'class="booth-nav-home"' in out # floating back chip
|
||||
assert 'href="/"' in out # points at the main booth index
|
||||
assert "all booths" in out
|
||||
assert FAVICON_LINK in out # favicon inherited
|
||||
assert "<h1>REPORT</h1>" in out # original content preserved
|
||||
# favicon lands in the head, chip lands in the body
|
||||
assert out.index(FAVICON_LINK) < out.index("</head>")
|
||||
assert out.index("booth-nav-home") > out.index("<body>")
|
||||
|
||||
|
||||
def test_wrap_respects_existing_favicon():
|
||||
html = '<html><head><link rel="icon" href="data:image/png;base64,AAAA"></head><body>x</body></html>'
|
||||
out = wrap_verbatim_html(html)
|
||||
assert FAVICON_LINK not in out # the page's own icon wins
|
||||
assert out.count('rel="icon"') == 1
|
||||
assert 'class="booth-nav-home"' in out # chip is still added
|
||||
|
||||
|
||||
def test_wrap_bare_fragment_appends_chip():
|
||||
out = wrap_verbatim_html("<h1>bare fragment</h1>") # no doctype/head/body
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert out.rstrip().endswith("</style>") # chip appended at the end
|
||||
assert FAVICON_LINK in out # no doctype -> safe to prepend the icon
|
||||
assert out.index(FAVICON_LINK) < out.index("bare") # icon ahead of content (implied head)
|
||||
|
||||
|
||||
def test_wrap_no_head_injects_favicon():
|
||||
out = wrap_verbatim_html("<body><h1>no head</h1></body>")
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert FAVICON_LINK in out # injected even without an explicit <head>
|
||||
|
||||
|
||||
def test_wrap_compact_doctype_stays_first():
|
||||
# the real-booth shape: compact HTML, no explicit head/body. The injection must
|
||||
# not push anything ahead of the doctype (quirks mode) or past the charset window.
|
||||
html = "<!doctype html><meta charset=utf-8><title>T</title><style>body{margin:0}</style><h1>REPORT</h1>"
|
||||
out = wrap_verbatim_html(html)
|
||||
assert out.lstrip().lower().startswith("<!doctype") # doctype still first -> standards mode
|
||||
assert FAVICON_LINK in out
|
||||
assert out.index(FAVICON_LINK) < out.index("<h1>") # icon in the implied head, before content
|
||||
assert out.index("charset") < 1024 # charset meta stays in the detection window
|
||||
assert 'class="booth-nav-home"' in out
|
||||
assert out.index("booth-nav-home") > out.index("<h1>REPORT</h1>") # chip appended after content
|
||||
|
||||
|
||||
def test_verbatim_booth_wrapped_with_back_chip(client):
|
||||
def test_verbatim_booth_is_served_with_the_seam(client):
|
||||
c, data = client
|
||||
d = data / "brief"
|
||||
d.mkdir()
|
||||
@@ -518,13 +480,11 @@ def test_verbatim_booth_wrapped_with_back_chip(client):
|
||||
r = c.get("/b/brief/")
|
||||
assert r.status_code == 200
|
||||
assert "BRIEF" in r.text # content preserved
|
||||
assert 'class="booth-nav-home"' in r.text # back chip injected
|
||||
assert 'href="/"' in r.text
|
||||
assert 'rel="icon"' in r.text # favicon inherited
|
||||
assert r.text.endswith(EMBED_SCRIPT_TAG) # ...and the seam, appended
|
||||
|
||||
|
||||
def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the chip
|
||||
# the file route (/b/<name>/index.html) still serves the raw bytes — the seam
|
||||
# only rides on the booth view (/b/<name>/), so downloads/assets stay verbatim
|
||||
c, data = client
|
||||
d = data / "brief"
|
||||
@@ -532,7 +492,7 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
|
||||
(d / "index.html").write_text("<html><body><h1>BRIEF</h1></body></html>")
|
||||
r = c.get("/b/brief/index.html")
|
||||
assert r.status_code == 200
|
||||
assert "booth-nav-home" not in r.text
|
||||
assert "_booth/embed.js" not in r.text
|
||||
|
||||
|
||||
# ---- .md / .txt in-booth doc viewer -----------------------------------------
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""U3 — the declared embed seam, server side.
|
||||
|
||||
The Booth used to reach into a verbatim report with ten regular expressions: six
|
||||
to find somewhere to hang a favicon and a chip, four to substitute rendered ask
|
||||
markup into the author's own tags. This unit replaces all of it with a seam the
|
||||
page declares:
|
||||
|
||||
<script src="/_booth/embed.js" defer></script>
|
||||
|
||||
What is tested here is the SERVER half — the payload that crosses the seam, the
|
||||
one static asset, and the single conditional append that is now the only thing
|
||||
the Booth does to author HTML. The half that mounts fragments into a live DOM
|
||||
lives in tests/test_embed_browser.py, because no amount of string assertion can
|
||||
see whether a form actually submits.
|
||||
|
||||
Contract: docs/contracts/u3_declared_embed_seam.contract.md
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from booth.app import EMBED_SCRIPT_TAG, EMBED_SRC, create_app
|
||||
from booth.marks import answer_pick, declare_pick, set_flag, write_note
|
||||
|
||||
DECLARED = f'<!doctype html><title>r</title><body>hi<script src="{EMBED_SRC}" defer></script></body>'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
return TestClient(app), tmp_path
|
||||
|
||||
|
||||
def _pick(booth, stem="winner", **kw):
|
||||
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
|
||||
doc.update(kw)
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, stem, doc)
|
||||
return booth
|
||||
|
||||
|
||||
def _multi(booth, stem="batch"):
|
||||
booth.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(booth, stem, {"title": "Round one", "questions": [
|
||||
{"key": "r1", "prompt": "First?", "options": ["a", "b"]},
|
||||
{"key": "r2", "prompt": "Second?", "options": ["a", "b"]},
|
||||
]})
|
||||
return booth
|
||||
|
||||
|
||||
# ---- slice 1: the payload ----------------------------------------------------
|
||||
|
||||
|
||||
def test_embed_payload_carries_a_fragment_for_every_shape(client):
|
||||
c, data = client
|
||||
_multi(data / "b")
|
||||
body = c.get("/b/b/embed.json").json()
|
||||
assert body["booth"] == "b" and body["home"] == "/"
|
||||
assert body["favicon"].startswith("data:image/svg+xml,")
|
||||
(m,) = body["marks"]
|
||||
assert m["id"] == "batch" and m["error"] is None
|
||||
assert "First?" in m["whole"] and "Second?" in m["whole"]
|
||||
assert 'action="/b/b/answer"' in m["submit"]
|
||||
assert [q["key"] for q in m["questions"]] == ["r1", "r2"] # declaration order
|
||||
assert "First?" in m["questions"][0]["html"]
|
||||
assert 'type="radio"' in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_a_single_question_pick_has_one_question_with_a_null_key(client):
|
||||
"""SR-2. `normalize_ask` gives a single-question ask `key: None`, so the
|
||||
payload cannot key questions by name — JSON would write that as "null" and
|
||||
invent a name. Every one-question ask in the fleet hits this."""
|
||||
c, data = client
|
||||
_pick(data / "b")
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert [q["key"] for q in m["questions"]] == [None]
|
||||
assert "Which render wins?" in m["questions"][0]["html"]
|
||||
|
||||
|
||||
def test_marks_are_ordered_created_then_id(client):
|
||||
c, data = client
|
||||
b = _pick(data / "b", "zebra")
|
||||
_pick(b, "alpha")
|
||||
# force identical creation stamps: the id is the tie-break, not json order
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||
(b / ".marks.json").write_text(json.dumps(raw))
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["alpha", "zebra"]
|
||||
|
||||
|
||||
def test_open_is_computed_by_the_server_not_the_page(client):
|
||||
"""INV-4. The chip count follows `open_marks`, which is the ONE openness
|
||||
predicate — a half-answered multi-question pick is still open."""
|
||||
c, data = client
|
||||
b = _multi(data / "b")
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["batch"]
|
||||
answer_pick(b, "batch", {"r1": "a"})
|
||||
assert c.get("/b/b/embed.json").json()["open"] == ["batch"] # partial is OPEN
|
||||
answer_pick(b, "batch", {"r1": "a", "r2": "b"})
|
||||
assert c.get("/b/b/embed.json").json()["open"] == []
|
||||
|
||||
|
||||
def test_the_payload_carries_picks_only(client):
|
||||
"""SR-4. Notes and flags never reach it, which is also what keeps a flag's
|
||||
`flag:<target>` id — the one mark id containing the anchor separator — out
|
||||
of a payload whose specs split on the first colon."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "shot.png").write_bytes(b"x")
|
||||
write_note(b, None, "a remark")
|
||||
set_flag(b, "shot.png", True)
|
||||
assert [m["id"] for m in c.get("/b/b/embed.json").json()["marks"]] == ["winner"]
|
||||
|
||||
|
||||
def test_a_damaged_marks_file_does_not_500_the_report(client):
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / ".marks.json").write_text("{not json")
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["marks"] == [] and r.json()["error"]
|
||||
|
||||
|
||||
def test_a_broken_pick_offers_whole_and_nothing_else(client):
|
||||
c, data = client
|
||||
b = _pick(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))
|
||||
(m,) = c.get("/b/b/embed.json").json()["marks"]
|
||||
assert m["error"] and m["questions"] == [] and m["submit"] == ""
|
||||
assert "broken ask" in m["whole"]
|
||||
|
||||
|
||||
def test_the_payload_does_not_record_a_view(client):
|
||||
"""`booth_view` already recorded the look, above both of its early returns.
|
||||
A script's fetch of the page it is already on must not count a second time
|
||||
or reset the TTL on machinery instead of on the operator."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
c.get("/b/b/")
|
||||
before = os.stat(b / ".viewed").st_mtime_ns
|
||||
time.sleep(0.01)
|
||||
c.get("/b/b/embed.json")
|
||||
assert os.stat(b / ".viewed").st_mtime_ns == before
|
||||
|
||||
|
||||
def test_embed_payload_404s_for_an_unknown_booth(client):
|
||||
c, _ = client
|
||||
assert c.get("/b/nope/embed.json").status_code == 404
|
||||
|
||||
|
||||
# ---- slice 2: the one static asset -------------------------------------------
|
||||
|
||||
|
||||
def test_embed_js_is_served_as_javascript(client):
|
||||
c, _ = client
|
||||
r = c.get(EMBED_SRC)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/javascript")
|
||||
assert "data-booth-mark" in r.text
|
||||
|
||||
|
||||
def test_embed_js_does_not_hot_reload_from_disk(tmp_path):
|
||||
"""INV-5, and the 2026-09-21 lesson restated. A live asset editable under a
|
||||
running process is how 19 of 25 booths hit 500 with the Python from 22:03
|
||||
and the templates from 23:40. One rule in this repo: nothing takes effect
|
||||
until you restart."""
|
||||
import pathlib
|
||||
|
||||
import booth.app as app_mod
|
||||
|
||||
src = pathlib.Path(app_mod.__file__).parent / "static" / "embed.js"
|
||||
original = src.read_text()
|
||||
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
|
||||
c = TestClient(app)
|
||||
served = c.get(EMBED_SRC).text
|
||||
try:
|
||||
src.write_text("/* POISONED */\n")
|
||||
assert c.get(EMBED_SRC).text == served, "embed.js is being re-read per request"
|
||||
finally:
|
||||
src.write_text(original)
|
||||
|
||||
|
||||
# ---- slice 3: what the Booth does to author HTML -----------------------------
|
||||
|
||||
|
||||
def test_declaring_page_is_served_untouched(client):
|
||||
"""INV-1. Whole-body equality, not a substring absence: the promise is that
|
||||
NOTHING is added, and an absence assertion cannot tell a clean page from one
|
||||
carrying something nobody thought to look for."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
assert c.get("/b/b/").text == DECLARED
|
||||
|
||||
|
||||
def test_undeclared_page_gains_only_the_tag(client):
|
||||
"""INV-2. Appended, so the source is a strict prefix — nothing is inserted,
|
||||
nothing is prepended, and neither the doctype nor the charset window moves."""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
src = "<!doctype html><meta charset=utf-8><title>r</title><h1>REPORT</h1>"
|
||||
(b / "index.html").write_text(src)
|
||||
out = c.get("/b/b/").text
|
||||
assert out == src + EMBED_SCRIPT_TAG
|
||||
assert out.startswith(src)
|
||||
assert out.lower().lstrip().startswith("<!doctype")
|
||||
assert out.index("charset") < 1024
|
||||
|
||||
|
||||
def test_a_booth_with_no_marks_still_gets_the_seam(client):
|
||||
"""The seam carries the way home and the icon too, so it is not conditional
|
||||
on there being an ask — the old chip was not either."""
|
||||
c, data = client
|
||||
(data / "b").mkdir()
|
||||
(data / "b" / "index.html").write_text("<h1>bare fragment</h1>")
|
||||
assert c.get("/b/b/").text == "<h1>bare fragment</h1>" + EMBED_SCRIPT_TAG
|
||||
|
||||
|
||||
def test_no_regex_touches_author_html():
|
||||
"""INV-3, and the version that actually falsifies it.
|
||||
|
||||
The first draft of this test name-matched the six deleted patterns. A cold
|
||||
panel pointed out — correctly, and on the contract's CENTRAL promise — that
|
||||
reintroducing the same regex under a new name (`_TAIL_RE`, applied in the
|
||||
verbatim branch) would leave it green. A test that guards names does not
|
||||
guard behaviour, and this repo's own vacuity pass missed it because the
|
||||
mutation it tried was the named one.
|
||||
|
||||
So: `booth/app.py` is allowed EXACTLY ONE regex operation, and it is
|
||||
`ask_form_id`'s `re.sub` over a mark id — not over a page. Any other regex
|
||||
anywhere in the module fails here, whatever it is called. If a future
|
||||
change genuinely needs one, the failure is the conversation: say which
|
||||
string it reads and why it is not author HTML.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
import booth.app as app_mod
|
||||
|
||||
root = pathlib.Path(app_mod.__file__).parent
|
||||
assert not (root / "inline.py").exists(), "booth/inline.py survived U3"
|
||||
tree = ast.parse((root / "app.py").read_text())
|
||||
|
||||
# every node -> the function it sits in, so a finding names its site
|
||||
site = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
for child in ast.walk(node):
|
||||
site.setdefault(child, node.name)
|
||||
|
||||
# `re` reaches this module ONE way: a plain module-level `import re`. An
|
||||
# alias (`import re as _r`) or a direct name import (`from re import sub`)
|
||||
# would route around the call check below under a name it does not know —
|
||||
# found by re-running the vacuity pass against the FIXED test, which is the
|
||||
# only reason it is here and is the argument for running that pass on a fix
|
||||
# and not only on a draft.
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for a in node.names:
|
||||
assert not (a.name == "re" and a.asname), f"`re` aliased as {a.asname}"
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
assert node.module != "re", f"names imported from re: {[a.name for a in node.names]}"
|
||||
|
||||
METHODS = {"search", "sub", "subn", "match", "fullmatch", "finditer",
|
||||
"findall", "split", "compile", "escape"}
|
||||
found = []
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
f = node.func
|
||||
on_re = isinstance(f.value, ast.Name) and f.value.id == "re"
|
||||
on_pattern = (isinstance(f.value, ast.Name) and f.value.id.endswith("_RE")
|
||||
and f.attr in METHODS)
|
||||
if on_re or on_pattern:
|
||||
found.append((site.get(node, "<module level>"), f.attr))
|
||||
|
||||
assert found == [("ask_form_id", "sub")], (
|
||||
f"booth/app.py performs regex operations outside ask_form_id: {found}"
|
||||
)
|
||||
|
||||
# the six named patterns and the chips are gone, and stay gone
|
||||
assigned = {
|
||||
t.id
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Assign)
|
||||
for t in node.targets
|
||||
if isinstance(t, ast.Name)
|
||||
}
|
||||
gone = {"_ICON_RE", "_HEAD_CLOSE_RE", "_HTML_OPEN_RE", "_DOCTYPE_RE",
|
||||
"_BODY_CLOSE_RE", "_HTML_CLOSE_RE", "_BACK_CHIP", "FAVICON_LINK"}
|
||||
assert not (assigned & gone), f"deleted names are back: {sorted(assigned & gone)}"
|
||||
funcs = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not ({"wrap_verbatim_html", "asks_chip", "inject_asks",
|
||||
"_insert_before", "_insert_after"} & funcs)
|
||||
|
||||
|
||||
def test_a_page_that_only_mentions_the_path_is_not_declaring_it(client):
|
||||
"""A report that QUOTES the seam — a code sample, a comment, a sentence
|
||||
about this very feature — is not declaring it, and the Booth's own design
|
||||
reports are the pages most likely to do that. Read as declared, such a page
|
||||
would be served untouched and show no chrome at all, silently.
|
||||
|
||||
The detection therefore fails the other way: an unrecognised spelling gets a
|
||||
duplicate tag, and embed.js mounts once regardless.
|
||||
"""
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
for body in (
|
||||
"<!doctype html><body><p>add <code>/_booth/embed.js</code> to your report</p></body>",
|
||||
"<!doctype html><body><!-- src=/_booth/embed.js --></body>",
|
||||
'<!doctype html><body><script src="/_booth/embed.js?v=2"></script></body>',
|
||||
):
|
||||
(b / "index.html").write_text(body)
|
||||
assert c.get("/b/b/").text == body + EMBED_SCRIPT_TAG, body
|
||||
|
||||
# and the real declaration, in either quote style, is honoured
|
||||
for decl in (f'<script src="{EMBED_SRC}" defer></script>',
|
||||
f"<script src='{EMBED_SRC}' defer></script>"):
|
||||
body = f"<!doctype html><body>hi{decl}</body>"
|
||||
(b / "index.html").write_text(body)
|
||||
assert c.get("/b/b/").text == body
|
||||
|
||||
|
||||
def test_an_oversize_verbatim_page_is_served_raw(client, monkeypatch):
|
||||
"""WRAP_MAX_BYTES survives: a pathological file is still not pulled into
|
||||
memory, and it loses its chrome exactly as it does today."""
|
||||
import booth.app as app_mod
|
||||
|
||||
c, data = client
|
||||
b = _pick(data / "b")
|
||||
(b / "index.html").write_text("<h1>huge</h1>")
|
||||
monkeypatch.setattr(app_mod, "WRAP_MAX_BYTES", 4)
|
||||
assert c.get("/b/b/").text == "<h1>huge</h1>"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""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)
|
||||
|
||||
|
||||
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
|
||||
a 400 the operator meets only after filling the form in."""
|
||||
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")
|
||||
assert page.locator('input[name="choice.r2"]').count() == 2 # never dropped
|
||||
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. Two picks whose creation order and id order disagree: the page must
|
||||
render them `(created, id)`, the order every other surface reads."""
|
||||
base, data = live
|
||||
b = _single(data / "b")
|
||||
_multi(b)
|
||||
raw = json.loads((b / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
e["created"] = "2026-09-22T10:00:00.000000-07:00"
|
||||
(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
|
||||
# identical `created`, so the id is the tie-break: batch before winner,
|
||||
# every fragment of one ahead of every fragment of the other.
|
||||
assert max(batch) < min(winner), 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 gets a 400.
|
||||
|
||||
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")
|
||||
assert target.startswith("#bk-ask-batch")
|
||||
assert page.locator(target).count() == 1
|
||||
page.close()
|
||||
|
||||
|
||||
def test_the_chip_does_not_jump_to_a_mark_that_merely_shares_a_prefix(browser, live):
|
||||
"""A cold panel read the chip rule as a bare prefix match and pointed out
|
||||
that `bk-ask-batch2-...` starts with `bk-ask-batch`. It does not match: the
|
||||
rule is the id EXACTLY, or the id followed by a hyphen. An author element
|
||||
can collide too, so the fixture plants one."""
|
||||
from booth.marks import declare_pick
|
||||
|
||||
base, data = live
|
||||
b = _multi(data / "b")
|
||||
declare_pick(b, "batch2", {"prompt": "Unrelated?", "options": ["x", "y"]})
|
||||
page = _open(browser, base, "b",
|
||||
f'<!doctype html><body><div id="bk-ask-batchX"></div>{SEAM}</body>', b)
|
||||
page.wait_for_selector(".booth-nav-asks")
|
||||
target = page.locator(".booth-nav-asks").get_attribute("href")
|
||||
assert target != "#bk-ask-batchX"
|
||||
landed = page.locator(target)
|
||||
assert landed.count() == 1
|
||||
# whatever it points at belongs to `batch` itself, not to `batch2`
|
||||
assert "batch2" not in target
|
||||
assert landed.locator('input[name^="choice."]').count() > 0 or "batch" in target
|
||||
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()
|
||||
Reference in New Issue
Block a user