"""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 "
x