From c85a700141339ad44652e52ed888f9fedcd51139 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Wed, 9 Sep 2026 09:20:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(booth):=20multi-question=20asks=20?= =?UTF-8?q?=E2=80=94=20a=20`questions`=20list=20renders=20one=20form=20wit?= =?UTF-8?q?h=20a=20radio=20group=20per=20question=20and=20lands=20as=20one?= =?UTF-8?q?=20answer=20sidecar=20keyed=20by=20question?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. / notes. / 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 --- services/booth/README.md | 32 +++- services/booth/booth/app.py | 29 ++- services/booth/booth/asks.py | 206 +++++++++++++++++----- services/booth/booth/templates/base.html | 7 + services/booth/booth/templates/booth.html | 46 +++-- services/booth/pyproject.toml | 2 +- services/booth/scripts/booth | 8 + services/booth/tests/test_asks.py | 94 ++++++++++ 8 files changed, 355 insertions(+), 69 deletions(-) diff --git a/services/booth/README.md b/services/booth/README.md index bf9b958..72a0372 100644 --- a/services/booth/README.md +++ b/services/booth/README.md @@ -121,15 +121,38 @@ EOF curl -sf http://10.100.10.50:8090/b/r18-ab/winner.answer.json # 404 until answered ``` -The answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes", +**Several questions, one form.** Give the ask a `questions` list instead of +`prompt`+`options`; the page renders one form with a radio group per question +and a single submit, every question required. Per-question `notes: true` adds +a small text field under that question; the form-level `notes` stays one field +for the whole ask. The answer is keyed by question: + +```bash +cat > ~/booth-data/r18-ab/batch.ask.json <<'EOF' +{"title": "R18 batch review", + "questions": [ + {"key": "r1", "prompt": "Render 1 — keep?", "options": ["keep", "drop"], "notes": true}, + {"key": "r2", "prompt": "Render 2 — keep?", "options": ["keep", "drop"]}, + {"key": "seed", "prompt": "Reseed the batch?", "options": ["yes", "no"]}], + "notes": true, "notes_label": "anything else"} +EOF +# -> batch.answer.json: {"stem", "title", "answers": {"r1": {"prompt", "choice", +# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"} +``` + +The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes", "answered_at", "answered_by"}` — `choice` is the option id (the label itself for string options), `choice_index` its 0-based position, `answered_by` the -client address. `POST /b//answer` (form fields `ask`, `choice`, `notes`) -is what the form submits; a bad choice is a 400, an unknown stem a 404. +client address. `POST /b//answer` is what the form submits — fields `ask` plus +`choice` / `notes` (single) or `choice.` / `notes.` / `notes` (multi); +a missing or bad choice is a 400, an unknown stem a 404. Rules of the primitive: -- **Radio, one pick.** ≥ 2 options, ≤ 40. No multi-select (not yet asked for). +- **Radio, one pick per question.** ≥ 2 options, ≤ 40 per question, ≤ 30 + questions per ask. No multi-select checkboxes (not yet asked for). Many asks + per booth are fine — each is its own form and its own sidecar; use + `questions` when the picks belong together and should land as one answer. - **Re-answering overwrites.** The sidecar is the *current* answer, not a log. The page shows the recorded answer with a collapsed *change answer* form. - **Open asks are flagged** — an amber `? N asks` badge on the index card and in @@ -293,6 +316,7 @@ Config is env in the unit (`booth.service`): ```bash cd services/booth uv venv && uv pip install fastapi "uvicorn[standard]" jinja2 python-multipart # runtime deps +ln -sfn "$PWD/scripts/booth" ~/.local/bin/booth # the `booth` CLI on PATH (nh3-dev has this) cp booth.service ~/.config/systemd/user/booth.service systemctl --user daemon-reload && systemctl --user enable --now booth.service ``` diff --git a/services/booth/booth/app.py b/services/booth/booth/app.py index 5f54f86..a4bd335 100644 --- a/services/booth/booth/app.py +++ b/services/booth/booth/app.py @@ -82,6 +82,7 @@ from booth.asks import ( # noqa: E402 is_answer_file, is_ask_file, list_asks, + load_ask, valid_stem, write_answer, ) @@ -654,18 +655,30 @@ def create_app( ) @app.post("/b/{name}/answer") - def booth_answer(request: Request, name: str, ask: str = Form(...), choice: str = Form(...), - notes: str = Form("")): - """Record the operator's answer to one ask: validates the choice - against the ask's options and writes `.answer.json` atomically. - Re-submitting overwrites — the sidecar is the current answer. 404 for - an unknown/invalid stem, 400 for a choice the ask does not offer.""" + async def booth_answer(request: Request, name: str): + """Record the operator's answer to one ask: validates every choice + against the ask and writes `.answer.json` atomically. + Re-submitting overwrites — the sidecar is the current answer. + + Form fields: `ask` (stem); single-question → `choice` + `notes`; + multi-question → `choice.` per question, optional `notes.`, + plus the form-level `notes`. 404 for an unknown/invalid stem, 400 for + a missing choice or one the ask does not offer. + """ booth = resolve_booth(name) - if not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file(): + form = await request.form() + ask = form.get("ask") + if not isinstance(ask, str) or not valid_stem(ask) or not (booth / f"{ask}{ASK_SUFFIX}").is_file(): raise HTTPException(status_code=404, detail="no such ask") who = request.client.host if request.client else "" try: - write_answer(booth, ask, choice, notes, who=who) + spec = load_ask(booth, ask) + if spec["multi"]: + choice = {q["key"]: form.get(f"choice.{q['key']}") for q in spec["questions"]} + qnotes = {q["key"]: form.get(f"notes.{q['key']}") for q in spec["questions"]} + write_answer(booth, ask, choice, form.get("notes", ""), who=who, qnotes=qnotes) + else: + write_answer(booth, ask, form.get("choice"), form.get("notes", ""), who=who) except AskError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/b/{quote(name, safe='')}/#ask-{quote(ask, safe='')}", status_code=303) diff --git a/services/booth/booth/asks.py b/services/booth/booth/asks.py index 580007c..b73ff56 100644 --- a/services/booth/booth/asks.py +++ b/services/booth/booth/asks.py @@ -27,6 +27,15 @@ Answer schema (what the operator's submit writes, atomically): "answered_at": "2026-09-09T07:12:03-07:00", "answered_by": "10.100.10.20"} +Multi-question form (one submit, one sidecar): + + {"title": "R18 batch review", + "questions": [{"key": "q1", "prompt": "Render 1?", "options": ["keep", "drop"], "notes": true}, + {"key": "q2", "prompt": "Render 2?", "options": ["keep", "drop"]}], + "notes": true} + -> {"stem", "title", "answers": {"q1": {"prompt", "choice", "choice_index", "label", "notes"}, …}, + "notes", "answered_at", "answered_by"} + Re-answering overwrites: the sidecar is the current answer, not a log. A session that wants history keeps its own. """ @@ -47,8 +56,10 @@ LABEL_MAX = 400 DETAIL_MAX = 1000 NOTES_MAX = 8000 MAX_OPTIONS = 40 +MAX_QUESTIONS = 30 _STEM_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$") +_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$") class AskError(ValueError): @@ -71,19 +82,11 @@ def valid_stem(stem: str) -> bool: return bool(_STEM_RE.match(stem)) and ".." not in stem -def normalize_ask(raw: dict, stem: str) -> dict: - """Validate + normalise an ask document. Raises AskError on anything the - renderer could not honour. Options come back as [{id, label, detail}].""" - if not isinstance(raw, dict): - raise AskError("ask must be a JSON object") - prompt = raw.get("prompt") - if not isinstance(prompt, str) or not prompt.strip(): - raise AskError("ask needs a non-empty string `prompt`") - opts_in = raw.get("options") +def _normalize_options(opts_in, where: str) -> list[dict]: if not isinstance(opts_in, list) or len(opts_in) < 2: - raise AskError("ask needs a list `options` with at least 2 entries") + raise AskError(f"{where} needs a list `options` with at least 2 entries") if len(opts_in) > MAX_OPTIONS: - raise AskError(f"too many options (max {MAX_OPTIONS})") + raise AskError(f"{where}: too many options (max {MAX_OPTIONS})") options: list[dict] = [] seen: set[str] = set() for i, o in enumerate(opts_in): @@ -92,32 +95,109 @@ def normalize_ask(raw: dict, stem: str) -> dict: elif isinstance(o, dict): label = o.get("label") if not isinstance(label, str) or not label.strip(): - raise AskError(f"option {i} needs a non-empty string `label`") + raise AskError(f"{where} option {i} needs a non-empty string `label`") oid = o.get("id", label) detail = o.get("detail", "") or "" if not isinstance(oid, str) or not oid.strip(): - raise AskError(f"option {i} has a bad `id`") + raise AskError(f"{where} option {i} has a bad `id`") if not isinstance(detail, str): - raise AskError(f"option {i} has a non-string `detail`") + raise AskError(f"{where} option {i} has a non-string `detail`") else: - raise AskError(f"option {i} must be a string or an object") + raise AskError(f"{where} option {i} must be a string or an object") oid = oid.strip() if oid in seen: - raise AskError(f"duplicate option id {oid!r}") + raise AskError(f"{where}: duplicate option id {oid!r}") seen.add(oid) options.append({"id": oid, "label": label.strip()[:LABEL_MAX], "detail": detail.strip()[:DETAIL_MAX]}) - notes = raw.get("notes", True) - if not isinstance(notes, bool): - raise AskError("`notes` must be true/false") + return options + + +def _bool(raw: dict, key: str, default: bool, where: str) -> bool: + v = raw.get(key, default) + if not isinstance(v, bool): + raise AskError(f"{where}: `{key}` must be true/false") + return v + + +def normalize_ask(raw: dict, stem: str) -> dict: + """Validate + normalise an ask document. Raises AskError on anything the + renderer could not honour. + + Two shapes are accepted and both come back as `questions: [...]`: + + single {"prompt", "options", "notes"?, "notes_label"?} + -> one question, key None, `multi` False. Its answer keeps the + flat {choice, choice_index, label, notes} shape. + multi {"title"?, "questions": [{"key", "prompt", "options", "notes"?}, ...], + "notes"?, "notes_label"?} + -> one FORM, one submit, every question required; the answer is + {answers: {key: {...}}, notes}. Per-question `notes` (default + false) adds a small text field under that question; the + form-level `notes` (default true) is one field for the whole ask. + """ + if not isinstance(raw, dict): + raise AskError("ask must be a JSON object") + notes = _bool(raw, "notes", True, "ask") notes_label = raw.get("notes_label", "notes") if not isinstance(notes_label, str): raise AskError("`notes_label` must be a string") + notes_label = notes_label.strip()[:80] or "notes" + + if "questions" in raw: + if "prompt" in raw or "options" in raw: + raise AskError("an ask has EITHER `prompt`+`options` OR `questions`, not both") + qs_in = raw.get("questions") + if not isinstance(qs_in, list) or not qs_in: + raise AskError("`questions` must be a non-empty list") + if len(qs_in) > MAX_QUESTIONS: + raise AskError(f"too many questions (max {MAX_QUESTIONS})") + title = raw.get("title", "") + if not isinstance(title, str): + raise AskError("`title` must be a string") + questions: list[dict] = [] + keys: set[str] = set() + for i, q in enumerate(qs_in): + where = f"question {i}" + if not isinstance(q, dict): + raise AskError(f"{where} must be an object") + key = q.get("key") + if not isinstance(key, str) or not _KEY_RE.match(key): + raise AskError(f"{where} needs a `key` (letters, digits, . _ -)") + if key in keys: + raise AskError(f"duplicate question key {key!r}") + keys.add(key) + prompt = q.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise AskError(f"{where} needs a non-empty string `prompt`") + questions.append({ + "key": key, + "prompt": prompt.strip()[:PROMPT_MAX], + "options": _normalize_options(q.get("options"), where), + "notes": _bool(q, "notes", False, where), + }) + return { + "stem": stem, + "multi": True, + "title": title.strip()[:PROMPT_MAX], + "prompt": title.strip()[:PROMPT_MAX] or f"{len(questions)} questions", + "questions": questions, + "notes": notes, + "notes_label": notes_label, + } + + prompt = raw.get("prompt") + if not isinstance(prompt, str) or not prompt.strip(): + raise AskError("ask needs a non-empty string `prompt` (or a `questions` list)") + options = _normalize_options(raw.get("options"), "ask") return { "stem": stem, + "multi": False, + "title": "", "prompt": prompt.strip()[:PROMPT_MAX], - "options": options, + "questions": [{"key": None, "prompt": prompt.strip()[:PROMPT_MAX], "options": options, "notes": False}], + "options": options, # kept for single-question callers "notes": notes, - "notes_label": notes_label.strip()[:80] or "notes", + "notes_label": notes_label, } @@ -160,8 +240,9 @@ def list_asks(booth: Path) -> list[dict]: try: ask = load_ask(booth, stem) except AskError as exc: - out.append({"stem": stem, "prompt": None, "options": [], "notes": False, - "notes_label": "notes", "error": str(exc), "answer": None}) + out.append({"stem": stem, "multi": False, "title": "", "prompt": None, "questions": [], + "options": [], "notes": False, "notes_label": "notes", + "error": str(exc), "answer": None}) continue ask["error"] = None ask["answer"] = read_answer(booth, stem) @@ -169,25 +250,61 @@ def list_asks(booth: Path) -> list[dict]: return out -def write_answer(booth: Path, stem: str, choice: str, notes: str = "", who: str = "") -> dict: - """Record the operator's answer. Validates the choice against the ask, - writes `.answer.json` via temp-file + os.replace so a reader never - sees a half-written document. Returns the answer written.""" - ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid - idx = next((i for i, o in enumerate(ask["options"]) if o["id"] == choice), None) +def _pick(options: list[dict], choice, where: str) -> tuple[int, dict]: + if not isinstance(choice, str): + raise AskError(f"{where}: no choice given") + idx = next((i for i, o in enumerate(options) if o["id"] == choice), None) if idx is None: - raise AskError("choice is not one of the ask's options") - notes = (notes or "").replace("\r\n", "\n").strip()[:NOTES_MAX] - answer = { - "stem": stem, - "prompt": ask["prompt"], - "choice": choice, - "choice_index": idx, - "label": ask["options"][idx]["label"], - "notes": notes if ask["notes"] else "", + raise AskError(f"{where}: choice is not one of the options") + return idx, options[idx] + + +def _clean_notes(text) -> str: + return (text or "").replace("\r\n", "\n").strip()[:NOTES_MAX] + + +def write_answer(booth: Path, stem: str, choice, notes: str = "", who: str = "", + qnotes: dict | None = None) -> dict: + """Record the operator's answer. Validates every choice against the ask, + writes `.answer.json` via temp-file + os.replace so a reader never + sees a half-written document. Returns the answer written. + + `choice` is the option id (str) for a single-question ask, or a + {key: option id} dict for a multi-question ask — every question required. + `qnotes` is {key: text} for per-question notes fields (multi only). + """ + ask = load_ask(booth, stem) # raises AskError if the ask is gone/invalid + stamp = { "answered_at": datetime.now().astimezone().isoformat(timespec="seconds"), "answered_by": who or "", } + if ask["multi"]: + if not isinstance(choice, dict): + raise AskError("a multi-question ask needs a {key: choice} mapping") + qnotes = qnotes or {} + answers: dict[str, dict] = {} + for q in ask["questions"]: + idx, opt = _pick(q["options"], choice.get(q["key"]), f"question {q['key']!r}") + answers[q["key"]] = { + "prompt": q["prompt"], + "choice": opt["id"], + "choice_index": idx, + "label": opt["label"], + "notes": _clean_notes(qnotes.get(q["key"])) if q["notes"] else "", + } + answer = {"stem": stem, "title": ask["title"], "answers": answers, + "notes": _clean_notes(notes) if ask["notes"] else "", **stamp} + else: + idx, opt = _pick(ask["options"], choice, "ask") + answer = { + "stem": stem, + "prompt": ask["prompt"], + "choice": opt["id"], + "choice_index": idx, + "label": opt["label"], + "notes": _clean_notes(notes) if ask["notes"] else "", + **stamp, + } path = Path(booth) / f"{stem}{ANSWER_SUFFIX}" tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -195,13 +312,16 @@ def write_answer(booth: Path, stem: str, choice: str, notes: str = "", who: str return answer -def write_ask(booth: Path, stem: str, prompt: str, options: list, notes: bool = True, - notes_label: str = "notes") -> Path: - """Author an ask from code/CLI. Validates through the same normaliser the - renderer uses, so a session cannot post a question the page would reject.""" +def write_ask(booth: Path, stem: str, prompt: str | None = None, options: list | None = None, + notes: bool = True, notes_label: str = "notes", doc: dict | None = None) -> Path: + """Author an ask from code/CLI. Either (prompt, options, ...) for a + single-question ask, or `doc=` a full document (single or multi shape). + Validated through the same normaliser the renderer uses, so a session + cannot post a question the page would reject.""" if not valid_stem(stem): raise AskError("bad stem: letters, digits, . _ - only") - doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label} + if doc is None: + doc = {"prompt": prompt, "options": options, "notes": notes, "notes_label": notes_label} normalize_ask(doc, stem) booth = Path(booth) booth.mkdir(parents=True, exist_ok=True) diff --git a/services/booth/booth/templates/base.html b/services/booth/booth/templates/base.html index a41f705..6796260 100644 --- a/services/booth/booth/templates/base.html +++ b/services/booth/booth/templates/base.html @@ -291,6 +291,13 @@ .ask-change::-webkit-details-marker{display:none} .ask-formwrap[open]>.ask-change{margin-bottom:.4rem} .ask-formwrap:not([open])>.ask-change{color:var(--aus-bright-cyan)} + .ask-q{border:0;margin:0 0 .7rem;padding:0;min-width:0} + .ask-q:last-of-type{margin-bottom:0} + .ask-q-prompt{padding:0;margin:0 0 .35rem;font-size:.9rem;font-weight:600;color:var(--fg-0)} + .ask-qnotes{margin-top:.35rem;font-size:.82rem} + .ask-answer-q{padding:.3rem 0;border-bottom:1px dashed var(--border-subtle)} + .ask-answer-q:last-of-type{border-bottom:0} + .ask-answer-qprompt{display:block;font-size:.76rem;color:var(--fg-3)} .ask-options{display:flex;flex-direction:column;gap:.35rem} .ask-opt{display:flex;align-items:flex-start;gap:.6rem;padding:.5rem .65rem;cursor:pointer; border:1px solid var(--border-subtle);border-radius:var(--radius-md);background:var(--rk-well); diff --git a/services/booth/booth/templates/booth.html b/services/booth/booth/templates/booth.html index ea4f814..c5a009d 100644 --- a/services/booth/booth/templates/booth.html +++ b/services/booth/booth/templates/booth.html @@ -36,7 +36,7 @@
{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %} - {{ a.stem }}.ask.json + {{ a.stem }}.ask.json{% if a.multi %} · {{ a.questions|length }} questions{% endif %} {% if a.answer %}{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}{% endif %}
@@ -46,7 +46,17 @@

{{ a.prompt }}

{% if a.answer %}
-
{{ a.answer.label }}
+ {% if a.multi %} + {% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %} +
+ {{ q.prompt }} +
{{ qa.label if qa else '—' }}
+ {% if qa and qa.notes %}
{{ qa.notes }}
{% endif %} +
+ {% endfor %} + {% else %} +
{{ a.answer.label }}
+ {% endif %} {% if a.answer.notes %}
{{ a.answer.notes }}
{% endif %} {{ a.stem }}.answer.json
@@ -55,18 +65,28 @@ {% if a.answer %}change answer{% else %}answer{% endif %}
-
- {% for o in a.options %} - + {% for q in a.questions %} + {% set field = 'choice.' ~ q.key if a.multi else 'choice' %} + {% set qa = a.answer.answers.get(q.key) if (a.answer and a.multi) else a.answer %} +
+ {% if a.multi %}{{ loop.index }}. {{ q.prompt }}{% endif %} +
+ {% for o in q.options %} + + {% endfor %} +
+ {% if q.notes %} + + {% endif %} +
{% endfor %} -
{% if a.notes %} {% endif %} diff --git a/services/booth/pyproject.toml b/services/booth/pyproject.toml index 167a1d6..24156d6 100644 --- a/services/booth/pyproject.toml +++ b/services/booth/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "booth" -version = "0.1.9" +version = "0.1.10" description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator." requires-python = ">=3.11" dependencies = [ diff --git a/services/booth/scripts/booth b/services/booth/scripts/booth index b221b42..2054634 100755 --- a/services/booth/scripts/booth +++ b/services/booth/scripts/booth @@ -28,6 +28,9 @@ # writes .answer.json next to it. `answer --wait` blocks until that file # exists and prints it, so a session can `booth ask … && booth answer --wait …` # and carry on. Re-answering overwrites: the sidecar is the CURRENT answer. +# Several questions in ONE form: write .ask.json by hand with a +# `questions` list (see services/booth/README.md § Asks); `asks` and `answer` +# handle both shapes. # Remote sessions: rsync the ask in, then poll # http://10.100.10.50:8090/b//.answer.json (404 until answered). # @@ -226,8 +229,13 @@ if not asks: for a in asks: if a["error"]: state = "BROKEN " + a["error"] + elif a["answer"] and a["multi"]: + picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items()) + state = "answered %s (%s)" % (picks, a["answer"]["answered_at"]) elif a["answer"]: state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"]) + elif a["multi"]: + state = "open (%d questions)" % len(a["questions"]) else: state = "open" print("%-24s %s" % (a["stem"], state)) diff --git a/services/booth/tests/test_asks.py b/services/booth/tests/test_asks.py index 229b532..2146756 100644 --- a/services/booth/tests/test_asks.py +++ b/services/booth/tests/test_asks.py @@ -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": []})