Files
vh 87e2c5364c 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.
2026-09-22 10:43:41 -07:00

594 lines
25 KiB
Python

"""Picks: a session poses a multiple-choice question; the operator answers it in
the browser; the answer lands where the session reads it.
A `pick` is one shape of MARK (see tests/test_marks.py and booth/marks.py). What
stays here is what did not move: `normalize_ask`, the declaration validator, and
`build_answer`, which together carry the operator-settled 2026-09-09 semantics —
plus the page and route integration, retargeted from the two-sidecars-per-question
storage that marks replaced.
"""
import json
import pathlib
import pytest
from fastapi.testclient import TestClient
from booth.app import EMBED_SCRIPT_TAG, build_gallery, create_app, list_booths
from booth.asks import (
ANSWER_SUFFIX,
ASK_SUFFIX,
AskError,
build_answer,
normalize_ask,
)
from booth.marks import (
answer_pick,
declare_pick,
import_legacy_asks,
marks_for,
open_marks,
)
def _ask(booth, stem="winner", **kw):
"""Declare a pick, the way a session does now."""
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 _sidecar(booth, stem="winner", **kw):
"""Write a LEGACY `<stem>.ask.json`. Only for the tests that are about the
legacy files themselves — they are still excluded from the item list, and
still importable."""
doc = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
return booth
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
# ---- normalisation ----------------------------------------------------------
def test_normalize_string_options():
a = normalize_ask({"prompt": " Pick ", "options": ["x", "y"]}, "s")
assert a["prompt"] == "Pick"
assert a["options"] == [{"id": "x", "label": "x", "detail": ""}, {"id": "y", "label": "y", "detail": ""}]
assert a["notes"] is True and a["notes_label"] == "notes"
def test_normalize_object_options_and_flags():
a = normalize_ask(
{"prompt": "p", "options": [{"id": "a", "label": "A", "detail": "d"}, {"label": "B"}],
"notes": False, "notes_label": "why"},
"s",
)
assert a["options"][0] == {"id": "a", "label": "A", "detail": "d"}
assert a["options"][1] == {"id": "B", "label": "B", "detail": ""}
assert a["notes"] is False and a["notes_label"] == "why"
@pytest.mark.parametrize(
"doc",
[
{"options": ["a", "b"]},
{"prompt": "", "options": ["a", "b"]},
{"prompt": "p", "options": ["only"]},
{"prompt": "p", "options": "a,b"},
{"prompt": "p", "options": ["a", "a"]},
{"prompt": "p", "options": [{"id": "a"}, "b"]},
{"prompt": "p", "options": ["a", "b"], "notes": "yes"},
[],
],
)
def test_normalize_rejects(doc):
with pytest.raises(AskError):
normalize_ask(doc, "s")
# ---- files ------------------------------------------------------------------
def test_a_malformed_declaration_is_refused(tmp_path):
"""The unreadable-FILE case moved to the legacy importer, which surfaces it
as a broken mark rather than raising — see test_marks.py. What is left here
is the declaration itself being wrong, which is refused at the door."""
with pytest.raises(AskError):
normalize_ask("not an object", "x")
with pytest.raises(AskError):
declare_pick(tmp_path, "x", {"prompt": "no options"})
def test_marks_for_folds_answer_and_surfaces_errors(tmp_path):
_ask(tmp_path, "one")
_ask(tmp_path, "two")
(tmp_path / f"broken{ASK_SUFFIX}").write_text("[]")
(tmp_path / ".hidden.ask.json").write_text("{}") # dotfiles never listed
answer_pick(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
by = {m.id: m for m in marks_for(tmp_path)}
assert set(by) == {"one", "two"} # the broken SIDECAR is not a mark
assert by["one"].answer is None and by["one"].error is None
assert by["two"].answer["choice"] == "B — async"
assert by["two"].answer["choice_index"] == 1
assert by["two"].answer["notes"] == "less banding"
assert by["two"].answer["answered_by"] == "10.0.0.1"
# The broken legacy sidecar is not silently swallowed either — importing it
# lands a mark carrying the error, so a question the session believes it
# posted stays visible instead of vanishing.
import_legacy_asks(tmp_path)
broken = next(m for m in marks_for(tmp_path) if m.id == "broken")
assert broken.error and broken.options == []
def test_answer_validates_the_choice_that_was_made(tmp_path):
_sidecar(tmp_path)
with pytest.raises(AskError):
_shape("C — nope")
with pytest.raises(AskError):
_shape("A — baseline", doc={"prompt": "p", "options": ["only"]})
ans = _shape("A — baseline", notes=" ok \r\n")
assert ans["notes"] == "ok"
assert ans["answered_at"]
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
assert _shape("B — async")["choice_index"] == 1
def test_answer_drops_notes_when_the_pick_disables_them(tmp_path):
_sidecar(tmp_path, notes=False)
assert _shape("A — baseline", doc=dict(SINGLE, notes=False), notes="ignored")["notes"] == ""
def test_declare_pick_roundtrip_and_id_guard(tmp_path):
declare_pick(tmp_path / "b", "pick",
{"prompt": "Pick one", "options": ["x", {"id": "y", "label": "Y"}], "notes": False})
a = next(m for m in marks_for(tmp_path / "b") if m.id == "pick")
assert [o["id"] for o in a.options] == ["x", "y"] and a.notes_enabled is False
for bad in ("../x", ".hidden", "a/b", ""):
with pytest.raises(AskError):
declare_pick(tmp_path / "b", bad, {"prompt": "p", "options": ["a", "b"]})
with pytest.raises(AskError):
declare_pick(tmp_path / "b", "ok", {"prompt": "p", "options": ["solo"]})
# ---- gallery + index integration -------------------------------------------
def test_gallery_hides_legacy_ask_and_answer_files(tmp_path):
"""The legacy sidecars are never deleted (the ROADMAP says so), so they are
still on disk in live booths and must still not render as tiles."""
b = _sidecar(tmp_path / "b")
(b / "a.png").write_bytes(b"x")
(b / f"winner{ANSWER_SUFFIX}").write_text(json.dumps({"stem": "winner"}))
names = {it["name"] for it in build_gallery(b)}
assert names == {"a.png"}
def test_list_booths_counts_open_asks(tmp_path):
b = _ask(tmp_path / "b", "one")
_ask(b, "two")
answer_pick(b, "two", "A — baseline")
(tmp_path / "plain").mkdir()
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
assert by["b"]["marks_open"] == 1 and by["b"]["marks_total"] == 2
assert by["plain"]["marks_open"] == 0 and by["plain"]["marks_total"] == 0
assert by["b"]["count"] == 0 # ask/answer files are not "items"
# ---- routes -----------------------------------------------------------------
def test_booth_page_renders_open_ask_as_form(client):
c, data = client
_ask(data / "b")
html = c.get("/b/b/").text
assert "Which render wins?" in html
assert 'type="radio"' in html and 'name="choice"' in html
assert 'value="B — async"' in html
assert 'action="/b/b/answer"' in html
assert "<textarea" in html
assert "1 open" in html
def test_answer_route_records_the_pick_and_page_shows_it(client):
c, data = client
_ask(data / "b")
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
follow_redirects=False)
assert r.status_code == 303 and r.headers["location"] == "/b/b/#mark-winner"
ans = _answer_of(data / "b", "winner")
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
assert ans["answered_by"] # TestClient's client addr
html = c.get("/b/b/").text
assert "answered" in html and "less banding" in html
assert "1 open" not in html
# a session on ANOTHER host reads the judgment over HTTP — the capability the
# per-stem sidecar used to carry, now one request for the whole booth
doc = c.get("/b/b/marks.json").json()
assert doc["open"] == []
assert doc["marks"][0]["answer"]["choice"] == "B — async"
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
c, data = client
_ask(data / "b")
assert c.post("/b/b/answer", data={"ask": "winner", "choice": "Z"}).status_code == 400
assert c.post("/b/b/answer", data={"ask": "nosuch", "choice": "A — baseline"}).status_code == 404
assert c.post("/b/b/answer", data={"ask": "../x", "choice": "A — baseline"}).status_code == 404
assert not (data / "b" / f"winner{ANSWER_SUFFIX}").exists()
def test_answer_json_404s_until_answered(client):
c, data = client
_ask(data / "b")
assert c.get("/b/b/winner.answer.json").status_code == 404
def test_notes_field_hidden_when_disabled(client):
c, data = client
_ask(data / "b", notes=False)
html = c.get("/b/b/").text
assert 'name="notes"' not in html # the pick's free-text field
assert 'name="text"' in html # the add-a-note control stays
def test_index_card_shows_open_mark_badge(client):
c, data = client
_ask(data / "b")
html = c.get("/").text
assert "1 open" in html
# ---- multi-question asks ----------------------------------------------------
MULTI = {
"title": "R18 batch review",
"questions": [
{"key": "r1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": True},
{"key": "r2", "prompt": "Render 2?", "options": [{"id": "k", "label": "keep"}, {"id": "d", "label": "drop"}]},
],
"notes": True,
}
def _multi(booth, stem="batch", **kw):
"""Declare a multi-question pick, the way a session does now."""
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
declare_pick(booth, stem, doc)
return booth
SINGLE = {"prompt": "Which render wins?", "options": ["A — baseline", "B — async"]}
def _shape(choice, doc=None, stem="winner", **kw):
"""`build_answer` over a normalized declaration — the 2026-09-09 semantics
with no storage under them."""
return build_answer(normalize_ask(json.loads(json.dumps(doc or SINGLE)), stem), choice, **kw)
def _answer_of(booth, mark_id):
"""The recorded judgment for one pick — read back through marks, which is the
one read path now that `<stem>.answer.json` is gone."""
m = next(x for x in marks_for(booth) if x.id == mark_id)
return m.answer
def _multi_sidecar(booth, stem="batch", **kw):
"""The LEGACY multi sidecar — for the tests that are about the legacy files
themselves (still excluded from the item list, still importable)."""
doc = json.loads(json.dumps(MULTI)); doc.update(kw)
booth.mkdir(parents=True, exist_ok=True)
(booth / f"{stem}{ASK_SUFFIX}").write_text(json.dumps(doc))
return booth
def test_normalize_multi():
a = normalize_ask(MULTI, "batch")
assert a["multi"] is True and a["title"] == "R18 batch review"
assert [q["key"] for q in a["questions"]] == ["r1", "r2"]
assert a["questions"][0]["notes"] is True and a["questions"][1]["notes"] is False
assert a["questions"][1]["options"][0] == {"id": "k", "label": "keep", "detail": ""}
# single stays single, and exposes ONE question with key None
s = normalize_ask({"prompt": "p", "options": ["a", "b"]}, "s")
assert s["multi"] is False and s["questions"][0]["key"] is None
@pytest.mark.parametrize(
"doc",
[
{"questions": []},
{"questions": [{"prompt": "p", "options": ["a", "b"]}]}, # no key
{"questions": [{"key": "bad key", "prompt": "p", "options": ["a", "b"]}]},
{"questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]},
{"key": "x", "prompt": "q", "options": ["a", "b"]}]}, # dup key
{"questions": [{"key": "x", "prompt": "p", "options": ["only"]}]},
{"prompt": "p", "options": ["a", "b"], "questions": [{"key": "x", "prompt": "p", "options": ["a", "b"]}]},
],
)
def test_normalize_multi_rejects(doc):
with pytest.raises(AskError):
normalize_ask(doc, "s")
def test_multi_accepts_a_partial_answer(tmp_path):
"""Blanks are legal (operator ruling 2026-09-09): refusing the whole
submission because one of four was skipped threw away the three that were
made."""
_multi_sidecar(tmp_path)
a = _shape({"r1": "keep"}, MULTI, "batch") # r2 not submitted at all
assert a["complete"] is False and a["unanswered"] == ["r2"]
assert list(a["answers"]) == ["r1"]
b = _shape({"r1": "keep", "r2": ""}, MULTI, "batch") # r2 an empty radio group
assert b["unanswered"] == ["r2"] and b["complete"] is False
# a note without a pick is still worth keeping
c = _shape({"r1": "", "r2": "k"}, MULTI, "batch", qnotes={"r1": "undecided"})
assert c["answers"]["r1"] == {"prompt": "Render 1?", "choice": None,
"choice_index": None, "label": "", "notes": "undecided"}
assert c["unanswered"] == ["r1"]
# nothing at all is refused: it would flip the ask to answered with no decision
with pytest.raises(AskError):
_shape({"r1": "", "r2": ""}, MULTI, "batch")
# ...but notes alone are a real submission
d = _shape({"r1": "", "r2": ""}, MULTI, "batch", notes="ask me tomorrow")
assert d["complete"] is False and d["notes"] == "ask me tomorrow" and d["answers"] == {}
def test_single_ask_may_be_answered_with_notes_only(tmp_path):
_sidecar(tmp_path)
with pytest.raises(AskError):
answer_pick(tmp_path, "winner", "")
a = _shape("", notes="neither is right, rerun")
assert a["choice"] is None and a["complete"] is False
assert a["notes"] == "neither is right, rerun"
def test_multi_still_rejects_a_bad_option(tmp_path):
_multi_sidecar(tmp_path)
with pytest.raises(AskError):
_shape({"r1": "keep", "r2": "nope"}, MULTI, "batch")
with pytest.raises(AskError):
_shape("keep", MULTI, "batch") # wrong shape
ans = _shape({"r1": "drop", "r2": "k"}, MULTI, "batch", notes="overall fine",
qnotes={"r1": "banding", "r2": "ignored: notes off"})
assert list(ans["answers"]) == ["r1", "r2"]
assert ans["answers"]["r1"] == {"prompt": "Render 1?", "choice": "drop", "choice_index": 1,
"label": "drop", "notes": "banding"}
assert ans["answers"]["r2"]["choice"] == "k" and ans["answers"]["r2"]["notes"] == ""
assert ans["notes"] == "overall fine" and ans["title"] == "R18 batch review"
def test_multi_page_and_route(client):
c, data = client
_multi(data / "b")
html = c.get("/b/b/").text
assert "R18 batch review" in html and "2 questions" in html
assert 'name="choice.r1"' in html and 'name="choice.r2"' in html
assert 'name="notes.r1"' in html and 'name="notes.r2"' not in html
assert 'name="notes"' in html
# a partial submission is RECORDED, not refused
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"},
follow_redirects=False).status_code == 303
part = _answer_of(data / "b", "batch")
assert part["complete"] is False and part["unanswered"] == ["r2"]
assert "1/2" in c.get("/b/b/").text and "partial" in c.get("/b/b/").text
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
assert r.status_code == 303
ans = _answer_of(data / "b", "batch")
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r1"]["notes"] == "crisp"
assert ans["answers"]["r2"]["choice"] == "d" and ans["notes"] == "ship r1"
html = c.get("/b/b/").text
assert "answered" in html and "crisp" in html and "ship r1" in html
def test_declare_pick_accepts_a_full_multi_doc(tmp_path):
declare_pick(tmp_path / "b", "batch", MULTI)
assert next(m for m in marks_for(tmp_path / "b") if m.id == "batch").multi is True
with pytest.raises(AskError):
declare_pick(tmp_path / "b", "bad", {"questions": []})
# ---- verbatim-index booths ---------------------------------------------------
#
# A booth's own index.html is served VERBATIM, so the inline asks panel can never
# render on it. Found 2026-09-09 on `emmie-anchor`: a valid ask, listed by the
# CLI, invisible on the page with nothing to say so. The fix is a chip injected
# into the verbatim page plus a standalone /asks page that carries the forms.
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?" 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 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 c.get("/b/b/embed.json").json()["marks"] == []
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
c, data = client
b = _ask(data / "b")
(b / "index.html").write_text("<!doctype html><body>hi</body>")
page = c.get("/b/b/marks").text
assert "Which render wins?" in page and 'type="radio"' in page
assert 'name="back" value="marks"' in page
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "marks"},
follow_redirects=False)
assert r.headers["location"] == "/b/b/marks#mark-winner"
assert _answer_of(b, "winner")["choice"] == "B — async"
assert "answered" in c.get("/b/b/marks").text
def test_asks_page_on_a_booth_with_none(client):
c, data = client
(data / "b").mkdir()
assert "no marks" in c.get("/b/b/marks").text
def test_asks_page_404s_for_unknown_booth(client):
c, _ = client
assert c.get("/b/nope/asks").status_code == 404
def test_single_ask_keeps_its_title(tmp_path):
a = normalize_ask({"title": "emmie — pick the anchor", "prompt": "Which?",
"options": ["a", "b"]}, "s")
assert a["multi"] is False and a["title"] == "emmie — pick the anchor"
with pytest.raises(AskError):
normalize_ask({"title": 7, "prompt": "p", "options": ["a", "b"]}, "s")
def test_asks_page_shows_a_single_ask_title(client):
c, data = client
_ask(data / "b", title="emmie — pick the anchor")
assert "emmie — pick the anchor" in c.get("/b/b/marks").text
# ---- 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>
<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_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)
assert c.get("/b/b/").text == REPORT
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)
r = c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep", "notes.r1": "crisp",
"choice.r2": "d", "notes": "ship r1"}, follow_redirects=False)
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 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_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 ".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):
"""The browser must not block a partial submit — `required` on a radio group
is exactly what stopped the operator leaving one blank."""
c, data = client
b = _multi(data / "b")
(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(client):
c, data = client
b = _multi(data / "b")
(b / "index.html").write_text(REPORT)
c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"})
(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):
c, data = client
b = _multi(data / "b")
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "", "choice.r2": ""}).status_code == 400
assert _answer_of(b, "batch") is None # the pick stays OPEN, not falsely answered