fix(booth): asks were invisible in a booth serving its own index.html
A custom index.html is returned verbatim, so booth.html's asks panel never rendered there — a valid ask (emmie-anchor/anchor.ask.json) was listed by the CLI and shown nowhere, with nothing to say so. - panel extracted to _asks.html; new GET /b/<name>/asks standalone page - verbatim pages get an amber '? N open asks' chip beside the back chip - POST /answer honours back=asks so answering returns to that page - single-question asks now keep an optional 'title' (was silently dropped) - README + routes table; 8 regression tests; v0.1.12
This commit is contained in:
@@ -111,7 +111,8 @@ booth asks r18-ab # list a booth's asks + state
|
||||
# Options can carry an id + detail line instead of a bare label — write the
|
||||
# JSON yourself (booth.asks.write_ask validates the same way):
|
||||
cat > ~/booth-data/r18-ab/plan.ask.json <<'EOF'
|
||||
{"prompt": "Ship which?",
|
||||
{"title": "optional short label above the question",
|
||||
"prompt": "Ship which?",
|
||||
"options": [{"id": "a", "label": "Plan A", "detail": "smaller diff, no migration"},
|
||||
{"id": "b", "label": "Plan B", "detail": "cleaner, needs the DB change"}],
|
||||
"notes": true, "notes_label": "why / conditions"}
|
||||
@@ -160,6 +161,12 @@ Rules of the primitive:
|
||||
- **A broken ask is shown as broken**, not hidden: if the JSON does not
|
||||
validate, the page says why, so a session never thinks it posted a question
|
||||
the operator cannot see.
|
||||
- **A booth with its own `index.html` shows a chip, not the panel.** That page is
|
||||
served verbatim by design, so the inline panel cannot appear on it: the Booth
|
||||
injects an amber `? N open asks` chip (next to the back chip) linking to
|
||||
**`/b/<name>/asks`**, a standalone page carrying the real forms. Answering
|
||||
there returns there. Put the media in the booth and the ask beside it either
|
||||
way — the ask is never lost, whichever shape the booth takes.
|
||||
- Ask/answer files are not gallery items and do not count toward the booth's
|
||||
item count; they render as the panel above the gallery. Answering bumps the
|
||||
booth's mtime, so it lives another TTL — the session has 24h to read it.
|
||||
@@ -213,7 +220,8 @@ to a safe basename (no path traversal).
|
||||
| `GET /b/<name>/` | A booth (its `index.html`, else auto-gallery) |
|
||||
| `GET /b/<name>/<file>` | Serve a file out of the booth |
|
||||
| `POST /upload` | Upload files → new pickup booth; 303-redirects to `/b/<id>/` (id in `Location`) |
|
||||
| `POST /b/<name>/answer` | Answer an ask (form fields `ask` = stem, `choice` = option id, `notes`); writes `<stem>.answer.json`, 303 back to the booth |
|
||||
| `GET /b/<name>/asks` | The asks panel on its own page — the only place a verbatim-`index.html` booth can show its asks |
|
||||
| `POST /b/<name>/answer` | Answer an ask (fields `ask` = stem, `choice`/`choice.<key>`, `notes`/`notes.<key>`, `back`); writes `<stem>.answer.json`, 303 back |
|
||||
| `POST /b/<name>/delete` | Wipe a booth (the UI's "Wipe now" button) |
|
||||
| `POST /b/<name>/keep` | Pin a booth — exempt from the sweep |
|
||||
| `POST /b/<name>/unkeep` | Release the pin (the UI's "release" button on kept cards) |
|
||||
|
||||
@@ -393,6 +393,29 @@ _BACK_CHIP = (
|
||||
"@media print{.booth-nav-home{display:none}}</style>"
|
||||
)
|
||||
|
||||
# A booth's own index.html is served VERBATIM, so the asks panel — which lives in
|
||||
# the auto-gallery template — can never appear on it. Without this chip an ask
|
||||
# posted into a custom-report booth is INVISIBLE to the operator with nothing to
|
||||
# say so (found 2026-09-09 on `emmie-anchor`: valid ask, CLI listed it, page
|
||||
# showed nothing). Same injection mechanism as the back chip; it links to the
|
||||
# standalone /asks page, which renders the real forms.
|
||||
def asks_chip(name: str, open_count: int) -> str:
|
||||
if open_count < 1:
|
||||
return ""
|
||||
label = f"? {open_count} open ask" + ("" if open_count == 1 else "s")
|
||||
return (
|
||||
f'<a href="/b/{quote(name, safe="")}/asks" class="booth-nav-asks">{label}</a>'
|
||||
"<style>.booth-nav-asks{position:fixed;top:0;right:7.2rem;z-index:2147483647;"
|
||||
"display:inline-block;margin:.6rem;padding:.34rem .72rem;"
|
||||
"font:700 13px/1.25 ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;"
|
||||
"color:#171a23;text-decoration:none;letter-spacing:.01em;"
|
||||
"background:#ffe14e;border:1px solid #ffe14e;border-radius:8px;"
|
||||
"box-shadow:0 2px 10px rgba(0,0,0,.35);transition:filter .18s}"
|
||||
".booth-nav-asks:hover{filter:brightness(1.08)}"
|
||||
"@media print{.booth-nav-asks{display:none}}</style>"
|
||||
)
|
||||
|
||||
|
||||
WRAP_MAX_BYTES = 8 * 1024 * 1024 # above this, serve the verbatim page raw (unwrapped)
|
||||
|
||||
_ICON_RE = re.compile(r"<link\b[^>]*\brel\s*=\s*[\"']?[^\"'>]*icon", re.IGNORECASE)
|
||||
@@ -417,7 +440,7 @@ def _insert_after(html: str, pattern: re.Pattern, snippet: str) -> tuple[str, bo
|
||||
return html, False
|
||||
|
||||
|
||||
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str:
|
||||
def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK, extra: str = "") -> str:
|
||||
"""Inject a floating 'all booths' back-chip — and the Booth favicon, if the page
|
||||
declares none — into a booth's verbatim index.html, without altering the page's
|
||||
rendered content.
|
||||
@@ -443,12 +466,13 @@ def wrap_verbatim_html(html: str, favicon_link: str = FAVICON_LINK) -> str:
|
||||
else:
|
||||
html = favicon_link + html # bare fragment, no doctype: safe to prepend
|
||||
|
||||
chips = _BACK_CHIP + (extra or "")
|
||||
for pat in (_BODY_CLOSE_RE, _HTML_CLOSE_RE):
|
||||
html, done = _insert_before(html, pat, _BACK_CHIP)
|
||||
html, done = _insert_before(html, pat, chips)
|
||||
if done:
|
||||
break
|
||||
else:
|
||||
html = html + _BACK_CHIP # no </body>/</html>: append to the end
|
||||
html = html + chips # no </body>/</html>: append to the end
|
||||
return html
|
||||
|
||||
|
||||
@@ -612,8 +636,12 @@ def create_app(
|
||||
# a pathological large file falls back to serving raw, unwrapped.
|
||||
try:
|
||||
if own_index.stat().st_size <= WRAP_MAX_BYTES:
|
||||
open_asks = sum(1 for a in list_asks(booth) if a["answer"] is None and not a["error"])
|
||||
return HTMLResponse(
|
||||
wrap_verbatim_html(own_index.read_text(encoding="utf-8", errors="replace"))
|
||||
wrap_verbatim_html(
|
||||
own_index.read_text(encoding="utf-8", errors="replace"),
|
||||
extra=asks_chip(name, open_asks),
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -681,7 +709,25 @@ def create_app(
|
||||
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)
|
||||
# Land where the form was: the standalone /asks page for a verbatim booth
|
||||
# (its own index.html cannot show the recorded answer), else the booth.
|
||||
base = f"/b/{quote(name, safe='')}/"
|
||||
if form.get("back") == "asks":
|
||||
base = f"/b/{quote(name, safe='')}/asks"
|
||||
return RedirectResponse(url=f"{base}#ask-{quote(ask, safe='')}", status_code=303)
|
||||
|
||||
@app.get("/b/{name}/asks", response_class=HTMLResponse)
|
||||
def booth_asks_page(request: Request, name: str):
|
||||
"""The asks panel on its own page. Reachable from any booth, and the ONLY
|
||||
place a verbatim-index.html booth can show its asks — that page is served
|
||||
untouched by design, so the inline panel never renders there."""
|
||||
booth = resolve_booth(name)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"asks.html",
|
||||
{**base_ctx, "name": name, "name_url": quote(name, safe=""),
|
||||
"asks": list_asks(booth), "asks_page": True},
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/view", response_class=HTMLResponse)
|
||||
def booth_view_file(request: Request, name: str, f: str):
|
||||
|
||||
@@ -189,10 +189,16 @@ def normalize_ask(raw: dict, stem: str) -> dict:
|
||||
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")
|
||||
# `title` is optional on a single-question ask too — a short label above the
|
||||
# question. It used to be accepted and silently dropped, which is worse than
|
||||
# rejecting it: the session sees no error and the operator sees no title.
|
||||
title = raw.get("title", "")
|
||||
if not isinstance(title, str):
|
||||
raise AskError("`title` must be a string")
|
||||
return {
|
||||
"stem": stem,
|
||||
"multi": False,
|
||||
"title": "",
|
||||
"title": title.strip()[:PROMPT_MAX],
|
||||
"prompt": prompt.strip()[:PROMPT_MAX],
|
||||
"questions": [{"key": None, "prompt": prompt.strip()[:PROMPT_MAX], "options": options, "notes": False}],
|
||||
"options": options, # kept for single-question callers
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{# Shared asks panel — included by booth.html (auto-gallery view) and by
|
||||
asks.html (the standalone page a VERBATIM index.html booth links to, since
|
||||
a verbatim page is served as-is and can never render this inline). #}
|
||||
{# ASKS. A session left multiple-choice questions here for the operator
|
||||
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
|
||||
/answer, which writes `<stem>.answer.json` for the session to read. Works
|
||||
with JS off — plain form POST. Answered asks show the recorded answer and a
|
||||
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 }}">
|
||||
<header class="ask-head">
|
||||
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% 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 %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||
</header>
|
||||
{% if a.error %}
|
||||
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
|
||||
{% else %}
|
||||
{% if a.title and not a.multi %}<p class="ask-title">{{ a.title }}</p>{% endif %}
|
||||
<p class="ask-prompt">{{ a.prompt }}</p>
|
||||
{% if a.answer %}
|
||||
<div class="ask-answer">
|
||||
{% if a.multi %}
|
||||
{% 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>
|
||||
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ask-answer-choice">{{ a.answer.label }}</div>
|
||||
{% endif %}
|
||||
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
||||
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
|
||||
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||||
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
|
||||
<input type="hidden" name="ask" value="{{ a.stem }}">
|
||||
{# On the standalone page, come back HERE — the booth's own page is a
|
||||
verbatim report that cannot show the recorded answer. #}
|
||||
{% if asks_page %}<input type="hidden" name="back" value="asks">{% endif %}
|
||||
{% 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 %}
|
||||
<fieldset class="ask-q">
|
||||
{% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||||
<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
|
||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||
<span class="ask-opt-main">
|
||||
<span class="ask-opt-label">{{ o.label }}</span>
|
||||
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if q.notes %}
|
||||
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
{% if a.notes %}
|
||||
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
{% endif %}
|
||||
<div class="ask-actions">
|
||||
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ name }} · asks · The Booth{% endblock %}
|
||||
{% block content %}
|
||||
{# The asks page for a booth whose own index.html is served VERBATIM. That page
|
||||
cannot render the panel inline (it is returned untouched by design), so the
|
||||
injected chip links here instead. Same forms, same POST target — only the
|
||||
redirect differs, so answering lands back here rather than on the report. #}
|
||||
<div class="boothhead">
|
||||
<a class="back" href="/b/{{ name_url }}/">‹ {{ name }}</a>
|
||||
<h1>Asks</h1>
|
||||
{% set open_asks = asks|selectattr('answer', 'none')|rejectattr('error')|list|length %}
|
||||
<span class="sub">{% if open_asks %}<span class="badge badge-ask">{{ open_asks }} open</span> · {% endif %}{{ asks|length }} ask{{ '' if asks|length == 1 else 's' }}</span>
|
||||
</div>
|
||||
{% if asks %}
|
||||
{% include "_asks.html" %}
|
||||
{% else %}
|
||||
<div class="empty">This booth has no asks.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -275,6 +275,8 @@
|
||||
.ask.is-answered .ask-state{color:var(--aus-bright-green)}
|
||||
.ask.is-broken .ask-state{color:var(--aus-bright-red)}
|
||||
.ask-when{white-space:nowrap}
|
||||
.ask-title{margin:.8rem .9rem -.35rem;font-family:var(--font-mono);font-size:.7rem;
|
||||
letter-spacing:.08em;text-transform:uppercase;color:var(--fg-3)}
|
||||
.ask-prompt{margin:.85rem .9rem .5rem;font-size:1.02rem;font-weight:600;color:var(--fg-0);white-space:pre-wrap}
|
||||
.ask-error{margin:.8rem .9rem;color:var(--aus-bright-red);font-size:.85rem}
|
||||
.ask-answer{margin:.2rem .9rem .6rem;padding:.55rem .75rem;border:1px solid var(--border-subtle);
|
||||
|
||||
@@ -26,79 +26,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if asks %}
|
||||
{# ASKS. A session left multiple-choice questions here for the operator
|
||||
(`<stem>.ask.json`). Open ones render as a radio form; answering POSTs to
|
||||
/answer, which writes `<stem>.answer.json` for the session to read. Works
|
||||
with JS off — plain form POST. Answered asks show the recorded answer and a
|
||||
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 }}">
|
||||
<header class="ask-head">
|
||||
<span class="ask-state">{% if a.error %}⚠ broken{% elif a.answer %}✓ answered{% 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 %}<span class="ask-when">{{ a.answer.answered_at }}{% if a.answer.answered_by %} · {{ a.answer.answered_by }}{% endif %}</span>{% endif %}
|
||||
</header>
|
||||
{% if a.error %}
|
||||
<p class="ask-error">This ask could not be read: {{ a.error }}</p>
|
||||
{% else %}
|
||||
<p class="ask-prompt">{{ a.prompt }}</p>
|
||||
{% if a.answer %}
|
||||
<div class="ask-answer">
|
||||
{% if a.multi %}
|
||||
{% 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>
|
||||
{% if qa and qa.notes %}<pre class="ask-answer-notes">{{ qa.notes }}</pre>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ask-answer-choice">{{ a.answer.label }}</div>
|
||||
{% endif %}
|
||||
{% if a.answer.notes %}<pre class="ask-answer-notes">{{ a.answer.notes }}</pre>{% endif %}
|
||||
<span class="ask-answer-file">→ <a href="{{ a.stem }}.answer.json">{{ a.stem }}.answer.json</a></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<details class="ask-formwrap"{% if not a.answer %} open{% endif %}>
|
||||
<summary class="ask-change">{% if a.answer %}change answer{% else %}answer{% endif %}</summary>
|
||||
<form class="ask-form" method="post" action="/b/{{ name_url }}/answer">
|
||||
<input type="hidden" name="ask" value="{{ a.stem }}">
|
||||
{% 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 %}
|
||||
<fieldset class="ask-q">
|
||||
{% if a.multi %}<legend class="ask-q-prompt">{{ loop.index }}. {{ q.prompt }}</legend>{% endif %}
|
||||
<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
|
||||
{% if qa and qa.choice == o.id %}checked{% endif %}>
|
||||
<span class="ask-opt-main">
|
||||
<span class="ask-opt-label">{{ o.label }}</span>
|
||||
{% if o.detail %}<span class="ask-opt-detail">{{ o.detail }}</span>{% endif %}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if q.notes %}
|
||||
<textarea class="ask-notes ask-qnotes" name="notes.{{ q.key }}" rows="2" placeholder="notes on this one (optional)">{{ qa.notes if qa else '' }}</textarea>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
{% if a.notes %}
|
||||
<textarea class="ask-notes" name="notes" rows="3" placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||
{% endif %}
|
||||
<div class="ask-actions">
|
||||
<button type="submit" class="ask-submit">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% include "_asks.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if board %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "booth"
|
||||
version = "0.1.10"
|
||||
version = "0.1.12"
|
||||
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 = [
|
||||
|
||||
@@ -308,3 +308,76 @@ def test_write_ask_accepts_full_doc(tmp_path):
|
||||
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_gets_an_asks_chip(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 "booth-nav-asks" in html # ...with a chip pointing at the asks
|
||||
assert "1 open ask" in html
|
||||
assert "/b/b/asks" 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
|
||||
|
||||
Reference in New Issue
Block a user