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:
@@ -141,8 +141,14 @@ EOF
|
||||
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
|
||||
```
|
||||
|
||||
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
|
||||
for a blank single-question ask) and **`complete`** (false until every question
|
||||
has a pick). A reading session should check `complete` before acting on a
|
||||
multi-question answer, and treat a key in `unanswered` as "not decided", never
|
||||
as "declined".
|
||||
|
||||
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
|
||||
"answered_at", "answered_by"}` — `choice` is the option id (the label itself
|
||||
"unanswered", "complete", "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/<name>/answer` is what the form submits — fields `ask` plus
|
||||
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
|
||||
@@ -156,6 +162,19 @@ Rules of the primitive:
|
||||
`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.
|
||||
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
|
||||
question alone is a real outcome ("none of these", "not listened to yet"), and
|
||||
refusing the whole submission over one blank threw away the picks that WERE
|
||||
made. So every answered question is recorded, every blank one lands in
|
||||
`unanswered`, and `complete` says whether the set is finished. The radios carry
|
||||
no HTML `required`, so the browser does not block the submit either. A question
|
||||
left blank but carrying a note keeps the note (`choice: null`). The one refusal
|
||||
is a submission with **no pick anywhere and no notes** — a 400, because it would
|
||||
flip an open ask to "answered" while recording no decision, which is worse for
|
||||
the reading session than leaving it open. A choice that is not in the option
|
||||
list is still an error: that is a broken form, not a skipped question.
|
||||
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
|
||||
fills in the rest.
|
||||
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
|
||||
the booth header — so a waiting question is visible from the front page.
|
||||
- **A broken ask is shown as broken**, not hidden: if the JSON does not
|
||||
|
||||
@@ -34,7 +34,11 @@ Multi-question form (one submit, one sidecar):
|
||||
{"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"}
|
||||
"unanswered": ["q2"], "complete": false, "notes", "answered_at", "answered_by"}
|
||||
|
||||
A question left blank is legal: it lands in `unanswered` and is absent from
|
||||
`answers` (unless it carried a note). `complete` is false until every question
|
||||
has a pick. Only a submission with no pick AND no notes anywhere is refused.
|
||||
|
||||
Re-answering overwrites: the sidecar is the current answer, not a log. A
|
||||
session that wants history keeps its own.
|
||||
@@ -257,60 +261,103 @@ def list_asks(booth: Path) -> list[dict]:
|
||||
|
||||
|
||||
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(f"{where}: choice is not one of the options")
|
||||
return idx, options[idx]
|
||||
|
||||
|
||||
def _blank(choice) -> bool:
|
||||
"""A question the operator left alone. An empty string is what an unchecked
|
||||
radio group posts, and None is what a missing field looks like — both mean
|
||||
'no pick', neither is an error."""
|
||||
return choice is None or (isinstance(choice, str) and not choice.strip())
|
||||
|
||||
|
||||
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,
|
||||
"""Record the operator's answer. Validates the choices that were MADE,
|
||||
writes `<stem>.answer.json` via temp-file + os.replace so a reader never
|
||||
sees a half-written document. Returns the answer written.
|
||||
|
||||
PARTIAL ANSWERS ARE LEGAL (operator ruling 2026-09-09). A question left
|
||||
blank is a deliberate outcome — "none of these", "I did not listen to that
|
||||
one yet", "ask me later" — and refusing the whole submission because one of
|
||||
four was skipped threw away the three that were made. So:
|
||||
|
||||
* every question the operator DID answer is recorded and validated;
|
||||
* every one left blank is listed in `unanswered`, absent from `answers`;
|
||||
* `complete` says whether all of them were answered.
|
||||
|
||||
The one thing refused is a submission carrying NOTHING — no choice anywhere
|
||||
and no notes. That would flip an open ask to "answered" while recording no
|
||||
decision, which is strictly worse for the reading session than leaving it
|
||||
open. A choice that is offered but not in the option list is still an error:
|
||||
that is a broken form, not a skipped question.
|
||||
|
||||
`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).
|
||||
{key: option id} dict for a multi-question ask. `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 "",
|
||||
}
|
||||
form_notes = _clean_notes(notes) if ask["notes"] else ""
|
||||
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] = {}
|
||||
unanswered: list[str] = []
|
||||
for q in ask["questions"]:
|
||||
idx, opt = _pick(q["options"], choice.get(q["key"]), f"question {q['key']!r}")
|
||||
c = choice.get(q["key"])
|
||||
note = _clean_notes(qnotes.get(q["key"])) if q["notes"] else ""
|
||||
if _blank(c):
|
||||
unanswered.append(q["key"])
|
||||
if note: # a note without a pick is still worth keeping
|
||||
answers[q["key"]] = {"prompt": q["prompt"], "choice": None,
|
||||
"choice_index": None, "label": "", "notes": note}
|
||||
continue
|
||||
idx, opt = _pick(q["options"], c, 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 "",
|
||||
"notes": note,
|
||||
}
|
||||
picked = [k for k, v in answers.items() if v["choice"] is not None]
|
||||
if not picked and not form_notes and not any(v["notes"] for v in answers.values()):
|
||||
raise AskError("nothing to record — no choice made and no notes")
|
||||
answer = {"stem": stem, "title": ask["title"], "answers": answers,
|
||||
"notes": _clean_notes(notes) if ask["notes"] else "", **stamp}
|
||||
"unanswered": unanswered, "complete": not unanswered,
|
||||
"notes": form_notes, **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,
|
||||
}
|
||||
if _blank(choice):
|
||||
if not form_notes:
|
||||
raise AskError("nothing to record — no choice made and no notes")
|
||||
answer = {"stem": stem, "prompt": ask["prompt"], "choice": None,
|
||||
"choice_index": None, "label": "", "unanswered": [None],
|
||||
"complete": False, "notes": form_notes, **stamp}
|
||||
else:
|
||||
idx, opt = _pick(ask["options"], choice, "ask")
|
||||
answer = {
|
||||
"stem": stem,
|
||||
"prompt": ask["prompt"],
|
||||
"choice": opt["id"],
|
||||
"choice_index": idx,
|
||||
"label": opt["label"],
|
||||
"unanswered": [],
|
||||
"complete": True,
|
||||
"notes": form_notes,
|
||||
**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")
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
border-top:2px solid #e0b93c;border-radius:9px;background:rgba(128,140,160,.07);
|
||||
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
.bk-ask.bk-done{border-top-color:#3fae6a}
|
||||
.bk-ask.bk-skip{border-top-color:#6f7c8c}
|
||||
.bk-ask.bk-skip .bk-ask-tag{color:#8a97a6}
|
||||
.bk-ask-tag{display:block;margin-bottom:.5rem;font:700 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
letter-spacing:.12em;text-transform:uppercase;color:#c9a227}
|
||||
.bk-ask.bk-done .bk-ask-tag{color:#3fae6a}
|
||||
@@ -53,14 +55,17 @@
|
||||
{% macro question(a, q, form_id, name_url, standalone=False) %}
|
||||
{% set field = 'choice.' ~ q.key if a.multi else 'choice' %}
|
||||
{% set qa = (a.answer.answers.get(q.key) if a.multi else a.answer) if a.answer else None %}
|
||||
<div class="bk-ask{% if qa %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||
<span class="bk-ask-tag">{% if qa %}✓ answered{% else %}? your pick{% endif %}</span>
|
||||
{% set picked = qa and qa.choice is not none %}
|
||||
{% set skipped = a.answer and not picked %}
|
||||
<div class="bk-ask{% if picked %} bk-done{% elif skipped %} bk-skip{% endif %}" id="bk-ask-{{ a.stem }}{% if q.key %}-{{ q.key }}{% endif %}">
|
||||
<span class="bk-ask-tag">{% if picked %}✓ answered{% elif skipped %}— skipped{% else %}? your pick{% endif %}</span>
|
||||
<p class="bk-ask-prompt">{{ q.prompt }}</p>
|
||||
{% if qa %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>{% endif %}
|
||||
{% if picked %}<p class="bk-ask-was">recorded: <b>{{ qa.label }}</b>{% if qa.notes %} — {{ qa.notes }}{% endif %}</p>
|
||||
{% elif skipped %}<p class="bk-ask-was">left blank — pick one any time, or leave it{% if qa and qa.notes %}; note: {{ qa.notes }}{% endif %}</p>{% endif %}
|
||||
<div class="bk-ask-opts">
|
||||
{% for o in q.options %}
|
||||
<label class="bk-ask-opt">
|
||||
<input type="radio" name="{{ field }}" value="{{ o.id }}" required
|
||||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||||
{% if not standalone %}form="{{ form_id }}"{% endif %}
|
||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||
<span class="bk-ask-lab"><span>{{ o.label }}</span>
|
||||
@@ -82,7 +87,10 @@
|
||||
<div class="bk-ask{% if a.answer %} bk-done{% endif %}" id="bk-ask-{{ a.stem }}-submit">
|
||||
<form id="{{ form_id }}" method="post" action="/b/{{ name_url }}/answer"></form>
|
||||
<input type="hidden" name="ask" value="{{ a.stem }}" form="{{ form_id }}">
|
||||
<span class="bk-ask-tag">{% if a.answer %}✓ answered {{ a.answer.answered_at }}{% else %}? submit your picks{% endif %}</span>
|
||||
<span class="bk-ask-tag">{% if a.answer and a.answer.complete %}✓ answered {{ a.answer.answered_at }}
|
||||
{%- elif a.answer %}◐ {{ a.questions|length - (a.answer.unanswered|length) }} of {{ a.questions|length }} answered · {{ a.answer.answered_at }}
|
||||
{%- else %}? submit your picks{% endif %}</span>
|
||||
{% if not a.answer %}<p class="bk-ask-was">Answer what you can — blanks are fine, and you can come back.</p>{% endif %}
|
||||
{% if a.notes %}
|
||||
<textarea class="bk-ask-notes" name="notes" rows="3" form="{{ form_id }}"
|
||||
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
collapsed "change" form, since the sidecar is the CURRENT answer. #}
|
||||
<section class="asks">
|
||||
{% for a in asks %}
|
||||
<article class="ask{% if a.answer %} is-answered{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
|
||||
<article class="ask{% if a.answer and a.answer.complete %} is-answered{% elif a.answer %} is-partial{% elif a.error %} is-broken{% endif %}" id="ask-{{ a.stem }}">
|
||||
<header class="ask-head">
|
||||
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% else %}? open{% endif %}</span>
|
||||
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer and a.answer.complete %}✓ answered{% elif a.answer %}◐ partial{% else %}? open{% endif %}</span>
|
||||
<span class="ask-stem"><code>{{ a.stem }}.ask.json</code>{% if a.multi %} · {{ a.questions|length }} questions{% endif %}</span>
|
||||
<span class="board-spacer"></span>
|
||||
{% if a.answer and not a.answer.complete %}<span class="ask-part">{{ (a.questions|length) - (a.answer.unanswered|length) }}/{{ a.questions|length }}</span>{% endif %}
|
||||
{% if a.answer %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||
</header>
|
||||
{% if a.error %}
|
||||
@@ -26,7 +27,7 @@
|
||||
{% for q in a.questions %}{% set qa = a.answer.answers.get(q.key) %}
|
||||
<div class="ask-answer-q">
|
||||
<span class="ask-answer-qprompt">{{ q.prompt }}</span>
|
||||
<div class="ask-answer-choice">{{ qa.label if qa else '—' }}</div>
|
||||
<div class="ask-answer-choice{% if not (qa and qa.choice is not none) %} is-skipped{% endif %}">{{ qa.label if (qa and qa.choice is not none) else 'left blank' }}</div>
|
||||
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -52,7 +53,7 @@
|
||||
<div class="ask-options">
|
||||
{% for o in q.options %}
|
||||
<label class="ask-opt{% if qa and qa.choice == o.id %} is-current{% endif %}">
|
||||
<input type="radio" name="{{ field }}" value="{{ o.id }}" required
|
||||
<input type="radio" name="{{ field }}" value="{{ o.id }}"
|
||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||
<span class="ask-opt-main">
|
||||
<span class="ask-opt-label">{{ o.label }}</span>
|
||||
|
||||
@@ -267,6 +267,14 @@
|
||||
.ask{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow);
|
||||
border-radius:.5rem;background:var(--rk-panel);overflow:hidden}
|
||||
.ask.is-answered{border-top-color:var(--aus-bright-green)}
|
||||
/* Partial: answered SOME questions. Not a failure and not done — blanks are a
|
||||
legal outcome (operator ruling 2026-09-09), so it gets its own state rather
|
||||
than being forced into one of the other two. */
|
||||
.ask.is-partial{border-top-color:var(--aus-bright-blue)}
|
||||
.ask.is-partial .ask-state{color:var(--aus-bright-blue)}
|
||||
.ask-part{font-family:var(--font-mono);font-size:.68rem;color:var(--aus-bright-blue);font-weight:700}
|
||||
.ask-answer-choice.is-skipped{opacity:.55;font-style:italic}
|
||||
.ask-answer-choice.is-skipped::before{content:"— ";color:var(--fg-3)}
|
||||
.ask.is-broken{border-top-color:var(--aus-bright-red)}
|
||||
.ask-head{display:flex;align-items:center;gap:.6rem;padding:.4rem .8rem;
|
||||
border-bottom:1px solid var(--border-subtle);background:var(--rk-well);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user