"""Asks: session poses a multiple-choice question; operator answers in the browser; the answer lands as a sidecar the session reads.""" 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, list_asks, load_ask, normalize_ask, read_answer, write_answer, write_ask, ) def _ask(booth, stem="winner", **kw): 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_load_ask_reports_bad_json(tmp_path): (tmp_path / f"x{ASK_SUFFIX}").write_text("{not json") with pytest.raises(AskError): load_ask(tmp_path, "x") with pytest.raises(AskError): load_ask(tmp_path, "missing") def test_list_asks_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 write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1") asks = list_asks(tmp_path) by = {a["stem"]: a for a in asks} assert set(by) == {"one", "two", "broken"} 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" assert by["broken"]["error"] and by["broken"]["options"] == [] def test_write_answer_validates_choice_and_is_atomic(tmp_path): _ask(tmp_path) with pytest.raises(AskError): write_answer(tmp_path, "winner", "C — nope") with pytest.raises(AskError): write_answer(tmp_path, "nosuch", "A — baseline") ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n") assert ans["notes"] == "ok" assert ans["answered_at"] assert read_answer(tmp_path, "winner") == ans assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists() # re-answer overwrites — the sidecar is the CURRENT answer, not a log write_answer(tmp_path, "winner", "B — async") assert read_answer(tmp_path, "winner")["choice_index"] == 1 def test_write_answer_drops_notes_when_ask_disables_them(tmp_path): _ask(tmp_path, notes=False) assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == "" def test_write_ask_roundtrip_and_stem_guard(tmp_path): p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False) assert p.name == f"pick{ASK_SUFFIX}" a = load_ask(tmp_path / "b", "pick") assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False for bad in ("../x", ".hidden", "a/b", ""): with pytest.raises(AskError): write_ask(tmp_path / "b", bad, "p", ["a", "b"]) with pytest.raises(AskError): write_ask(tmp_path / "b", "ok", "p", ["solo"]) # ---- gallery + index integration ------------------------------------------- def test_gallery_hides_ask_and_answer_files(tmp_path): b = _ask(tmp_path / "b") (b / "a.png").write_bytes(b"x") write_answer(b, "winner", "A — baseline") 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") write_answer(b, "two", "A — baseline") (tmp_path / "plain").mkdir() by = {x["name"]: x for x in list_booths(tmp_path, 3600)} assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2 assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_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 "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") write_answer(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/asks").text assert "Which render wins?" in page and 'type="radio"' in page assert 'name="back" value="asks"' in page r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "asks"}, follow_redirects=False) assert r.headers["location"] == "/b/b/asks#ask-winner" assert read_answer(b, "winner")["choice"] == "B — async" assert "answered" in c.get("/b/b/asks").text def test_asks_page_on_a_booth_with_none(client): c, data = client (data / "b").mkdir() assert "no asks" in c.get("/b/b/asks").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/asks").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 = read_answer(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/asks").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 read_answer(b, "batch") is None # the ask stays OPEN, not falsely answered