fix(booth): a partial ask answer is recorded, not refused

Operator: the form failed when a question was left blank. Refusing the whole
submission over one blank threw away the picks that were made, and the HTML
`required` on the radios blocked it in the browser before the server saw it.

- answered questions recorded; blank ones land in `unanswered`; `complete` says
  whether the set is finished; a blank question carrying a note keeps the note
- `required` dropped from both templates so the browser cannot block a partial
- refused only when there is no pick anywhere AND no notes (a 400 — that would
  flip an open ask to answered with no decision recorded); a choice outside the
  option list is still an error
- new ◐ partial state with an n/N count; skipped questions render as skipped
- README + global CLAUDE.md tell reading sessions to check `complete`
- 154 tests; v0.1.15
This commit is contained in:
vh
2026-09-09 15:05:18 -07:00
parent 6d36119bc5
commit 88d3cf436e
7 changed files with 179 additions and 35 deletions
+66 -5
View File
@@ -264,10 +264,40 @@ def test_normalize_multi_rejects(doc):
normalize_ask(doc, "s")
def test_write_answer_multi_requires_every_question(tmp_path):
def test_write_answer_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(tmp_path)
a = write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 not submitted at all
assert a["complete"] is False and a["unanswered"] == ["r2"]
assert list(a["answers"]) == ["r1"]
b = write_answer(tmp_path, "batch", {"r1": "keep", "r2": ""}) # r2 an empty radio group
assert b["unanswered"] == ["r2"] and b["complete"] is False
# a note without a pick is still worth keeping
c = write_answer(tmp_path, "batch", {"r1": "", "r2": "k"}, 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):
write_answer(tmp_path, "batch", {"r1": "keep"}) # r2 missing
write_answer(tmp_path, "batch", {"r1": "", "r2": ""})
# ...but notes alone are a real submission
d = write_answer(tmp_path, "batch", {"r1": "", "r2": ""}, "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):
_ask(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "winner", "")
a = write_answer(tmp_path, "winner", "", "neither is right, rerun")
assert a["choice"] is None and a["complete"] is False
assert a["notes"] == "neither is right, rerun"
def test_write_answer_multi_still_rejects_a_bad_option(tmp_path):
_multi(tmp_path)
with pytest.raises(AskError):
write_answer(tmp_path, "batch", {"r1": "keep", "r2": "nope"})
with pytest.raises(AskError):
@@ -290,9 +320,12 @@ def test_multi_page_and_route(client):
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()
# 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 = json.loads((data / "b" / f"batch{ANSWER_SUFFIX}").read_text())
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
@@ -467,3 +500,31 @@ def test_styles_are_emitted_once(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('<!doctype html><body><div data-booth-ask="batch"></div></body>')
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('<!doctype html><body><div data-booth-ask="batch"></div></body>')
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