feat(booth): multi-question asks — a questions list renders one form with a radio group per question and lands as one answer sidecar keyed by question

- asks.py: single {prompt, options} and multi {title, questions:[{key, prompt, options, notes?}]} both normalise to questions[]; per-question notes; every question required on submit
- /answer reads choice.<key> / notes.<key> / notes for multi; single shape unchanged
- booth asks prints per-question picks; README + CLI header; install step symlinks the CLI to ~/.local/bin; v0.1.10; 135 tests
This commit is contained in:
vh
2026-09-09 09:20:35 -07:00
parent 97589dd062
commit 125a1b7fc5
8 changed files with 355 additions and 69 deletions
+94
View File
@@ -214,3 +214,97 @@ def test_index_card_shows_open_ask_badge(client):
_ask(data / "b")
html = c.get("/").text
assert "1 ask" 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):
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_write_answer_multi_requires_every_question(tmp_path):
_multi(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 missing
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
with pytest.raises(AskError):
write_answer(tmp_path, "batch", "keep") # wrong shape
ans = write_answer(tmp_path, "batch", {"r1": "drop", "r2": "k"}, "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"
assert read_answer(tmp_path, "batch") == ans
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
# incomplete submission → 400, nothing written
assert c.post("/b/b/answer", data={"ask": "batch", "choice.r1": "keep"}).status_code == 400
assert not (data / "b" / f"batch{ANSWER_SUFFIX}").exists()
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 = c.get("/b/b/batch.answer.json").json()
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_write_ask_accepts_full_doc(tmp_path):
write_ask(tmp_path / "b", "batch", doc=MULTI)
assert load_ask(tmp_path / "b", "batch")["multi"] is True
with pytest.raises(AskError):
write_ask(tmp_path / "b", "bad", doc={"questions": []})