c335c38c19
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
531 lines
21 KiB
Python
531 lines
21 KiB
Python
"""Asks: session poses a multiple-choice question; operator answers in the
|
|
browser; the answer lands as a sidecar the session reads."""
|
|
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,
|
|
list_asks,
|
|
load_ask,
|
|
normalize_ask,
|
|
read_answer,
|
|
write_answer,
|
|
write_ask,
|
|
)
|
|
|
|
|
|
def _ask(booth, stem="winner", **kw):
|
|
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_load_ask_reports_bad_json(tmp_path):
|
|
(tmp_path / f"x{ASK_SUFFIX}").write_text("{not json")
|
|
with pytest.raises(AskError):
|
|
load_ask(tmp_path, "x")
|
|
with pytest.raises(AskError):
|
|
load_ask(tmp_path, "missing")
|
|
|
|
|
|
def test_list_asks_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
|
|
write_answer(tmp_path, "two", "B — async", "less banding", who="10.0.0.1")
|
|
|
|
asks = list_asks(tmp_path)
|
|
by = {a["stem"]: a for a in asks}
|
|
assert set(by) == {"one", "two", "broken"}
|
|
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"
|
|
assert by["broken"]["error"] and by["broken"]["options"] == []
|
|
|
|
|
|
def test_write_answer_validates_choice_and_is_atomic(tmp_path):
|
|
_ask(tmp_path)
|
|
with pytest.raises(AskError):
|
|
write_answer(tmp_path, "winner", "C — nope")
|
|
with pytest.raises(AskError):
|
|
write_answer(tmp_path, "nosuch", "A — baseline")
|
|
ans = write_answer(tmp_path, "winner", "A — baseline", " ok \r\n")
|
|
assert ans["notes"] == "ok"
|
|
assert ans["answered_at"]
|
|
assert read_answer(tmp_path, "winner") == ans
|
|
assert not (tmp_path / f"winner{ANSWER_SUFFIX}.tmp").exists()
|
|
# re-answer overwrites — the sidecar is the CURRENT answer, not a log
|
|
write_answer(tmp_path, "winner", "B — async")
|
|
assert read_answer(tmp_path, "winner")["choice_index"] == 1
|
|
|
|
|
|
def test_write_answer_drops_notes_when_ask_disables_them(tmp_path):
|
|
_ask(tmp_path, notes=False)
|
|
assert write_answer(tmp_path, "winner", "A — baseline", "ignored")["notes"] == ""
|
|
|
|
|
|
def test_write_ask_roundtrip_and_stem_guard(tmp_path):
|
|
p = write_ask(tmp_path / "b", "pick", "Pick one", ["x", {"id": "y", "label": "Y"}], notes=False)
|
|
assert p.name == f"pick{ASK_SUFFIX}"
|
|
a = load_ask(tmp_path / "b", "pick")
|
|
assert [o["id"] for o in a["options"]] == ["x", "y"] and a["notes"] is False
|
|
for bad in ("../x", ".hidden", "a/b", ""):
|
|
with pytest.raises(AskError):
|
|
write_ask(tmp_path / "b", bad, "p", ["a", "b"])
|
|
with pytest.raises(AskError):
|
|
write_ask(tmp_path / "b", "ok", "p", ["solo"])
|
|
|
|
|
|
# ---- gallery + index integration -------------------------------------------
|
|
|
|
|
|
def test_gallery_hides_ask_and_answer_files(tmp_path):
|
|
b = _ask(tmp_path / "b")
|
|
(b / "a.png").write_bytes(b"x")
|
|
write_answer(b, "winner", "A — baseline")
|
|
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")
|
|
write_answer(b, "two", "A — baseline")
|
|
(tmp_path / "plain").mkdir()
|
|
by = {x["name"]: x for x in list_booths(tmp_path, 3600)}
|
|
assert by["b"]["asks_open"] == 1 and by["b"]["asks_total"] == 2
|
|
assert by["plain"]["asks_open"] == 0 and by["plain"]["asks_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 "<textarea" in html
|
|
assert "1 open ask" in html
|
|
|
|
|
|
def test_answer_route_writes_sidecar_and_page_shows_it(client):
|
|
c, data = client
|
|
_ask(data / "b")
|
|
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "notes": "less banding"},
|
|
follow_redirects=False)
|
|
assert r.status_code == 303 and r.headers["location"] == "/b/b/#ask-winner"
|
|
ans = json.loads((data / "b" / f"winner{ANSWER_SUFFIX}").read_text())
|
|
assert ans["choice"] == "B — async" and ans["notes"] == "less banding"
|
|
assert ans["answered_by"] # TestClient's client addr
|
|
html = c.get("/b/b/").text
|
|
assert "answered" in html and "less banding" in html
|
|
assert "1 open ask" not in html
|
|
# the sidecar is fetchable over HTTP for remote sessions
|
|
assert c.get("/b/b/winner.answer.json").json()["choice"] == "B — async"
|
|
|
|
|
|
def test_answer_route_rejects_bad_choice_and_unknown_ask(client):
|
|
c, data = client
|
|
_ask(data / "b")
|
|
assert c.post("/b/b/answer", data={"ask": "winner", "choice": "Z"}).status_code == 400
|
|
assert c.post("/b/b/answer", data={"ask": "nosuch", "choice": "A — baseline"}).status_code == 404
|
|
assert c.post("/b/b/answer", data={"ask": "../x", "choice": "A — baseline"}).status_code == 404
|
|
assert not (data / "b" / f"winner{ANSWER_SUFFIX}").exists()
|
|
|
|
|
|
def test_answer_json_404s_until_answered(client):
|
|
c, data = client
|
|
_ask(data / "b")
|
|
assert c.get("/b/b/winner.answer.json").status_code == 404
|
|
|
|
|
|
def test_notes_field_hidden_when_disabled(client):
|
|
c, data = client
|
|
_ask(data / "b", notes=False)
|
|
assert "<textarea" not in c.get("/b/b/").text
|
|
|
|
|
|
def test_index_card_shows_open_ask_badge(client):
|
|
c, data = 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_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": "", "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):
|
|
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
|
|
# 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
|
|
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": []})
|
|
|
|
|
|
# ---- verbatim-index booths ---------------------------------------------------
|
|
#
|
|
# A booth's own index.html is served VERBATIM, so the inline asks panel can never
|
|
# render on it. Found 2026-09-09 on `emmie-anchor`: a valid ask, listed by the
|
|
# CLI, invisible on the page with nothing to say so. The fix is a chip injected
|
|
# into the verbatim page plus a standalone /asks page that carries the forms.
|
|
|
|
|
|
def test_verbatim_booth_renders_the_ask_inline(client):
|
|
c, data = client
|
|
b = _ask(data / "b")
|
|
(b / "index.html").write_text("<!doctype html><title>report</title><body>hi</body>")
|
|
html = c.get("/b/b/").text
|
|
assert "hi" in html # the report is still served verbatim
|
|
assert "Which render wins?" in html # ...with the ask ON it, not elsewhere
|
|
assert 'type="radio"' in html and 'action="/b/b/answer"' in html
|
|
assert "bk-ask" in html # self-contained fragment styles
|
|
assert "booth-nav-asks" in html # chip remains, as a jump link
|
|
assert "#bk-ask-winner-top" in html
|
|
|
|
|
|
def test_verbatim_chip_disappears_once_answered(client):
|
|
c, data = client
|
|
b = _ask(data / "b")
|
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
|
write_answer(b, "winner", "A — baseline")
|
|
assert "booth-nav-asks" not in c.get("/b/b/").text
|
|
|
|
|
|
def test_verbatim_booth_without_asks_is_untouched(client):
|
|
c, data = client
|
|
(data / "b").mkdir()
|
|
(data / "b" / "index.html").write_text("<!doctype html><body>hi</body>")
|
|
assert "booth-nav-asks" not in c.get("/b/b/").text
|
|
|
|
|
|
def test_asks_page_renders_forms_and_answers_back_to_itself(client):
|
|
c, data = client
|
|
b = _ask(data / "b")
|
|
(b / "index.html").write_text("<!doctype html><body>hi</body>")
|
|
page = c.get("/b/b/asks").text
|
|
assert "Which render wins?" in page and 'type="radio"' in page
|
|
assert 'name="back" value="asks"' in page
|
|
r = c.post("/b/b/answer", data={"ask": "winner", "choice": "B — async", "back": "asks"},
|
|
follow_redirects=False)
|
|
assert r.headers["location"] == "/b/b/asks#ask-winner"
|
|
assert read_answer(b, "winner")["choice"] == "B — async"
|
|
assert "answered" in c.get("/b/b/asks").text
|
|
|
|
|
|
def test_asks_page_on_a_booth_with_none(client):
|
|
c, data = client
|
|
(data / "b").mkdir()
|
|
assert "no asks" in c.get("/b/b/asks").text
|
|
|
|
|
|
def test_asks_page_404s_for_unknown_booth(client):
|
|
c, _ = client
|
|
assert c.get("/b/nope/asks").status_code == 404
|
|
|
|
|
|
def test_single_ask_keeps_its_title(tmp_path):
|
|
a = normalize_ask({"title": "emmie — pick the anchor", "prompt": "Which?",
|
|
"options": ["a", "b"]}, "s")
|
|
assert a["multi"] is False and a["title"] == "emmie — pick the anchor"
|
|
with pytest.raises(AskError):
|
|
normalize_ask({"title": 7, "prompt": "p", "options": ["a", "b"]}, "s")
|
|
|
|
|
|
def test_asks_page_shows_a_single_ask_title(client):
|
|
c, data = client
|
|
_ask(data / "b", title="emmie — pick the anchor")
|
|
assert "emmie — pick the anchor" in c.get("/b/b/asks").text
|
|
|
|
|
|
# ---- inline placement in a verbatim report -----------------------------------
|
|
#
|
|
# Operator verdict 2026-09-09 on the separate /asks page: "the asks should be
|
|
# inline with the artifacts, not on a separate page." A four-voice audition wants
|
|
# each voice's radio group under that voice's audio, and one submit for the lot.
|
|
|
|
|
|
REPORT = """<!doctype html><title>audition</title><body>
|
|
<h1>Three voices</h1>
|
|
<section id="lawson"><audio src="a.wav"></audio>
|
|
<div data-booth-ask="batch:r1"></div></section>
|
|
<section id="jo"><audio src="b.wav"></audio>
|
|
<!-- booth:ask batch:r2 --></section>
|
|
<div data-booth-ask-submit="batch"></div>
|
|
</body>"""
|
|
|
|
|
|
def test_per_question_placeholders_land_where_the_author_put_them(client):
|
|
c, data = client
|
|
b = _multi(data / "b")
|
|
(b / "index.html").write_text(REPORT)
|
|
html = c.get("/b/b/").text
|
|
# each group is inside its own section, in document order
|
|
lawson = html.index('id="lawson"')
|
|
jo = html.index('id="jo"')
|
|
assert lawson < html.index('name="choice.r1"') < jo
|
|
assert jo < html.index('name="choice.r2"')
|
|
# one shared form, bound by the HTML5 form= attribute, submitted once
|
|
assert html.count('<form id="bk-ask-form-batch"') == 1
|
|
assert html.count('action="/b/b/answer"') == 1
|
|
assert html.count('form="bk-ask-form-batch"') >= 4
|
|
# the submit block landed at its own placeholder, not appended after </body>
|
|
assert html.index("bk-ask-form-batch") < html.index("</body>")
|
|
|
|
|
|
def test_inline_form_submits_every_question_in_one_post(client):
|
|
c, data = client
|
|
b = _multi(data / "b")
|
|
(b / "index.html").write_text(REPORT)
|
|
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 = read_answer(b, "batch")
|
|
assert ans["answers"]["r1"]["choice"] == "keep" and ans["answers"]["r2"]["choice"] == "d"
|
|
# and the recorded pick now shows inline, on the report itself
|
|
html = c.get("/b/b/").text
|
|
assert "recorded:" in html and "bk-done" in html
|
|
assert 'value="keep" required checked' in html.replace("\n", " ") or "checked" in html
|
|
|
|
|
|
def test_whole_ask_placeholder_renders_everything_there(client):
|
|
c, data = client
|
|
b = _ask(data / "b")
|
|
(b / "index.html").write_text('<!doctype html><body><p>x</p><div data-booth-ask="winner"></div></body>')
|
|
html = c.get("/b/b/").text
|
|
assert html.index("Which render wins?") > html.index("<p>x</p>")
|
|
assert html.index("bk-ask-go") < html.index("</body>") # submit placed inline too
|
|
|
|
|
|
def test_placeholder_for_a_missing_ask_is_left_alone(client):
|
|
c, data = client
|
|
b = _ask(data / "b")
|
|
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="typo"></div></body>')
|
|
html = c.get("/b/b/").text
|
|
assert 'data-booth-ask="typo"' in html # author's markup untouched, not blanked
|
|
assert "Which render wins?" in html # the real ask still appended, never lost
|
|
|
|
|
|
def test_questions_placed_without_a_submit_still_get_one(client):
|
|
c, data = client
|
|
b = _multi(data / "b")
|
|
(b / "index.html").write_text('<!doctype html><body><div data-booth-ask="batch:r1"></div></body>')
|
|
html = c.get("/b/b/").text
|
|
assert html.count('<form id="bk-ask-form-batch"') == 1 # appended, so it is submittable
|
|
assert 'name="choice.r2"' in html # r2 unplaced -> must still appear
|
|
|
|
|
|
def test_styles_are_emitted_once(client):
|
|
c, data = 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
|