"""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 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 `.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 ".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_renders_the_ask_inline(client): c, data = client b = _ask(data / "b") (b / "index.html").write_text("reporthi") 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 def test_verbatim_chip_disappears_once_answered(client): c, data = client b = _ask(data / "b") (b / "index.html").write_text("hi") answer_pick(b, "winner", "A — baseline") assert "booth-nav-asks" not in c.get("/b/b/").text def test_verbatim_booth_without_asks_is_untouched(client): c, data = client (data / "b").mkdir() (data / "b" / "index.html").write_text("hi") assert "booth-nav-asks" not in c.get("/b/b/").text def test_asks_page_renders_forms_and_answers_back_to_itself(client): c, data = client b = _ask(data / "b") (b / "index.html").write_text("hi") 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 # ---- inline 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. REPORT = """audition

Three voices

""" def test_per_question_placeholders_land_where_the_author_put_them(client): 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('
= 4 # the submit block landed at its own placeholder, not appended after assert html.index("bk-ask-form-batch") < html.index("") def test_inline_form_submits_every_question_in_one_post(client): 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 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 def test_whole_ask_placeholder_renders_everything_there(client): c, data = client b = _ask(data / "b") (b / "index.html").write_text('

x

') html = c.get("/b/b/").text assert html.index("Which render wins?") > html.index("

x

") assert html.index("bk-ask-go") < html.index("") # 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('
') 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('
') html = c.get("/b/b/").text assert html.count(' must still appear def test_styles_are_emitted_once(client): 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 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") assert "required" not in c.get("/b/b/").text (b / "index.html").write_text('
') assert "required" not in c.get("/b/b/").text assert "required" not in c.get("/b/b/marks").text def test_partial_answer_renders_as_skipped_inline(client): c, data = client b = _multi(data / "b") (b / "index.html").write_text('
') 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 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