fix(asks): one submit saves every ask on the page

Operator report (via infra-ops): on a page with several asks, a submit
saved only the pressed one and the reload wiped the rest. Confirmed on
auk-audition: one POST at 15:02:23 saved the last ask on the page, then a
400 from the submit of an ask the reload had just blanked.

Client-side on both surfaces; /answer is unchanged. A submit on a pick
form, while another pick form on the page holds unsent input, sends every
changed ("dirty") pick form: one POST each, to its own action, with
Accept: application/json, in document order. A refusal stops nothing, and
untouched forms are never re-sent. With no other dirty form, a submit is
exactly what it was.

- embed.js (verbatim reports): reloads only when nothing was refused and
  nothing of ours is dirty. Otherwise a server-rendered status line in the
  submit block says what did not save, and input stays. A form the server
  took gets a new baseline. A press during the flight is ignored.
- base.html (marks page, lightbox, review rail): one refresh in place. A
  batch never reloads. Only forms the server took count as sent. In-flight
  state and "just sent" are keyed by form identity (formKey) plus the fields
  at the press, not the DOM node.

Two heid bug-hunt rounds: a four-arm panel on the first cut, then Hulda
alone on the fold. Ten findings reproduced red in a browser before their
fixes. Contracts: U3 "Submitting several asks at once" + INV-8, R2 C3
steps 2, 3 and 3a. Mutation tables u3_submit_all (15) and r2_submit_all
(11), all proved. Suite 928 -> 951.
This commit is contained in:
vh
2026-09-27 17:05:11 -07:00
parent 34ac1683ee
commit 50bfc7b4ec
12 changed files with 1496 additions and 35 deletions
+3 -3
View File
@@ -118,7 +118,7 @@ new = ''' except OSError:
label = 'C3 client: no busy guard (a double-click writes twice)'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
old = ''' if (form.__busy) return;
old = ''' if (pending[flightKey(form)]) return;
'''
new = ''''''
@@ -133,8 +133,8 @@ new = ''''''
label = 'C3 client: saves are not serialized'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_quick_successive_flags_all_show"
old = ''' queue = queue.then(function () { return run(form, data); })'''
new = ''' queue = run(form, data)'''
old = ''' queue = queue.then(function () { return run(form, data, snap); })'''
new = ''' queue = run(form, data, snap)'''
[[mutation]]
label = 'C3 the standalone marks page has no region'
+109
View File
@@ -0,0 +1,109 @@
# R2 C3 step 3a, 2026-09-27: one submit saves every changed pick on a Booth
# page (the marks page, the lightbox's verdict aside, the review rail). The
# Booth-page half of the operator's report; the verbatim half is
# u3_submit_all.toml. Contract: docs/contracts/r2_flow.contract.md, C3 step 3a.
# The flight-window and refusal rows came from the heid bug-hunt panel on the
# first cut.
unit = "r2 submit all"
[[mutation]]
label = "the other pick forms are ignored (one form, one save, as before)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_one_submit_on_the_marks_page_saves_every_changed_pick"
old = '''
var batch = pickBatch(form);'''
new = '''
var batch = null;'''
[[mutation]]
label = "the pressed pick is sent even when blank (its 400 reloads the rest away)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it"
old = '''
return others ? picks.filter(function (f) { return dirty(f) && !pending[flightKey(f)]; }) : null;'''
new = '''
return others ? picks.filter(function (f) { return (f === form || dirty(f)) && !pending[flightKey(f)]; }) : null;'''
[[mutation]]
label = "a refusal stops the picks after it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
return post(f, datas[i]).then(function () {'''
new = '''
if (refused.length) return;
return post(f, datas[i]).then(function () {'''
[[mutation]]
label = "a refused batch reloads (the refused pick and every draft are lost)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
new = '''
if (refused.length) { fail(); return; }
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
[[mutation]]
label = "a refused pick counts as sent, so its input comes back as the server has it"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
old = '''
}, function (e) { refused.push(askOf(f) + ' (' + e.message + ')'); });'''
new = '''
}, function (e) { saved.push({key: flightKey(f), snap: snaps[i]}); refused.push(askOf(f) + ' (' + e.message + ')'); });'''
[[mutation]]
label = "a form in flight stops counting as another dirty form (a clean press 400s mid-save)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_pressing_a_clean_pick_during_a_batch_sends_nothing"
old = '''
return f.getAttribute('action') === action && isPick(f);'''
new = '''
return f.getAttribute('action') === action && isPick(f) && !pending[flightKey(f)];'''
[[mutation]]
label = "in flight is marked on the node, so a swap's fresh copy can be sent twice"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_pick_in_flight_stays_in_flight_across_another_saves_swap"
old = '''
function flightKey(f) { return formKey(f); }'''
new = '''
var nth = 0;
function flightKey(f) { return f.__fk || (f.__fk = 'n' + (++nth)); }'''
[[mutation]]
label = "a new save does not clear the last one's words"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_later_save_clears_a_stale_not_saved_line"
old = '''
if (st) { st.textContent = ''; st.hidden = true; }'''
new = ''''''
[[mutation]]
label = "a batch whose refresh fails reloads (every unsent draft with it)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_batch_whose_refresh_fails_keeps_the_page"
old = '''
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
new = '''
var shown = saved.length ? refresh(saved, true).catch(function (e) { fail(); throw e; }) : Promise.resolve(true);'''
[[mutation]]
label = "a sent form counts as sent even when it changed after the press"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_change_to_a_sent_pick_during_the_flight_is_kept"
old = '''
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
new = '''
if (recs[i].key === k) return true;'''
[[mutation]]
label = "no form counts as sent (a saved note's text carries back as a draft)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_saved_notes_box_comes_back_empty"
old = '''
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
new = '''
if (false) return true;'''
+141
View File
@@ -0,0 +1,141 @@
# U3 amendment, 2026-09-27: one submit saves every ask on a verbatim report.
# The operator answered three asks top to bottom, pressed the last button, and
# the 303 reload wiped the first two (`auk-audition`, 15:02:23). Contract:
# docs/contracts/u3_declared_embed_seam.contract.md, "Submitting several asks at
# once" and INV-8. Every row is a change tests/test_embed_browser.py claims to
# forbid. The flight-window rows came from the heid bug-hunt panel on the first
# cut, where every guard of that window survived its mutation.
unit = "u3 submit all"
[[mutation]]
label = "only the pressed form is sent (the reported defect)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_one_submit_saves_every_answered_ask_on_the_page[a3]"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = [form];'''
[[mutation]]
label = "the pressed form is sent whether or not it is dirty (a blank one 400s)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_blank_ask_is_skipped_never_refused"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms.filter(function (f) { return f === form || dirty(f); });'''
[[mutation]]
label = "every form is sent, touched or not (re-dates an answer nobody gave)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_ask_nobody_touched_is_not_re_sent"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms;'''
[[mutation]]
label = "a lone dirty form is intercepted instead of left to the browser"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_one_changed_ask_still_submits_as_a_plain_form"
old = '''
if (!others) return; // the browser's own POST and 303'''
new = ''''''
[[mutation]]
label = "the forms are sent in reverse document order"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_the_asks_are_sent_in_document_order"
old = '''
var batch = forms.filter(dirty);'''
new = '''
var batch = forms.filter(dirty).reverse();'''
[[mutation]]
label = "a refusal stops the forms after it"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
return send(f, bodies[n]).then(function (why) {'''
new = '''
if (failed.length) return;
return send(f, bodies[n]).then(function (why) {'''
[[mutation]]
label = "a refusal reloads the page and clears what was entered"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
if (!failed.length && !changed) { location.reload(); return; }'''
new = '''
location.reload(); return;'''
[[mutation]]
label = "only dirtiness blocks the reload (a refused form set back to its first value reloads over the failure)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty"
old = '''
if (!failed.length && !changed) { location.reload(); return; }'''
new = '''
if (!changed) { location.reload(); return; }'''
[[mutation]]
label = "a refusal is never said"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
old = '''
st.hidden = false;'''
new = ''''''
[[mutation]]
label = "an author's own form is taken over (no ownership check at the entry)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
old = '''
if (forms.indexOf(form) < 0) return;'''
new = ''''''
[[mutation]]
label = "an author's form wearing our id prefix counts as ours (no mounted-root check)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
old = '''
if (ours[j].contains(all[i])) { out.push(all[i]); break; }'''
new = '''
out.push(all[i]); break;'''
[[mutation]]
label = "a press during the flight falls through to the browser (a native POST races the batch)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_press_inside_the_flight_never_fires_a_native_post"
old = '''
if (sending) { ev.preventDefault(); return; }'''
new = ''''''
[[mutation]]
label = "a change made during the flight is reloaded away"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press"
old = '''
var changed = forms.some(dirty);'''
new = '''
var changed = false;'''
[[mutation]]
label = "a saved form keeps its old baseline, so a retry re-sends (re-dates) it"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_retry_after_a_refusal_sends_only_what_did_not_save"
old = '''
if (why === null) f.__bkSaved = bodies[n].toString();
else failed.push'''
new = '''
if (why !== null) failed.push'''
[[mutation]]
label = "the empty status line shows under a host `p{display:block}`"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_the_empty_status_line_stays_hidden_under_host_css"
old = '''
".bk-ask-status[hidden]{display:none}",'''
new = ''''''
+359
View File
@@ -670,3 +670,362 @@ def test_the_test_browser_has_no_internet(browser, live):
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
# ---- one submit saves every ask on the page (2026-09-27) ---------------------
#
# The operator's report, relayed by infra-ops: "submitting a question should go
# through and submit ALL answers. As it is, I go through, submit a question and
# it only submits the last one and clears out the top ones." Confirmed against
# the live `auk-audition` booth before any code: three single-question asks,
# each its own <form>; the access log shows ONE POST at 15:02:23 saving the
# LAST ask on the page, its 303 reload wiping the other two, then a 400 when a
# now-blank ask was submitted. Contract: u3_declared_embed_seam, "Submitting
# several asks at once".
def _asks(booth, ids=("a1", "a2", "a3")):
"""Single-question asks, declared in `ids` order, which is also their id
order, so `(created, id)` cannot disagree with the order written here."""
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
for mid in ids:
declare_pick(booth, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
return booth
def _answers(booth):
raw = json.loads((booth / ".marks.json").read_text())
return {m["id"]: m.get("answer") for m in raw["marks"]}
def _choice(page, mid, value):
page.check(f'input[name="choice"][form="bk-ask-form-{mid}"][value="{value}"]')
def _press(page, mid):
page.click(f"#bk-ask-{mid}-submit button.bk-ask-go")
def _watch_posts(page):
"""Every POST the page makes, as (resource type, ask id), in send order. A
native form submission is a `document` request; a script's is a `fetch`."""
from urllib.parse import parse_qs
posts = []
def seen(r):
if r.method == "POST":
ask = parse_qs(r.post_data or "").get("ask", [None])[0]
posts.append((r.resource_type, ask))
page.on("request", seen)
return posts
PLAIN = f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>"
@pytest.mark.parametrize("pressed", ["a3", "a1"])
def test_one_submit_saves_every_answered_ask_on_the_page(browser, live, pressed):
"""The operator's exact sequence: answer top to bottom, press the LAST
submit. And the same from the FIRST button, because "press any one" is the
acceptance. Every ask he answered is recorded, and after the page comes
back every pick he made is still showing."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, pressed)
got = _answers(b)
assert {k: (v or {}).get("choice") for k, v in got.items()} == \
{"a1": "yes", "a2": "no", "a3": "yes"}, got
page.wait_for_selector("#bk-ask-a3-submit")
showing = page.evaluate("""() => ['a1', 'a2', 'a3'].map(id => {
var c = document.querySelector('input[name="choice"][form="bk-ask-form-' + id + '"]:checked');
return c ? c.value : null; })""")
page.close()
assert showing == ["yes", "no", "yes"], f"a pick disappeared on reload: {showing}"
def test_a_blank_ask_is_skipped_never_refused(browser, live):
"""Pressing the submit of an ask he left blank used to be a 400 page
("nothing to record"): the live log has one, four seconds after the reload
that wiped his picks. Blanks stay legal: the ask is skipped, never sent, and
the others are saved."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a1", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "no" and got["a3"]["choice"] == "yes", got
assert got["a2"] is None, "a blank ask was recorded"
assert [a for _, a in posts] == ["a1", "a3"], posts
def test_an_ask_nobody_touched_is_not_re_sent(browser, live):
"""Re-sending an answer re-dates it, and a reading session sees a fresh
answer that nobody gave. Only what changed since the page loaded is sent —
not the recorded answer sitting under the button that was pressed."""
from booth.marks import answer_pick
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "no")
with page.expect_navigation():
_press(page, "a1") # the "Update answer" button
page.close()
assert [a for _, a in posts] == ["a2"], posts
assert _answers(b)["a2"]["choice"] == "no"
def test_one_changed_ask_still_submits_as_a_plain_form(browser, live):
"""With nothing else on the page to save, a submit is exactly what it was:
the browser's own form POST and its 303. The batch is an addition that
engages only when another ask holds unsent input."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
assert posts == [("document", "a2")], posts
assert _answers(b)["a2"]["choice"] == "yes"
def test_the_asks_are_sent_in_document_order(browser, live):
"""INV-6: the batch is an ordered collection, and its rule is the order the
forms sit in the document — here the REVERSE of the payload's, because the
author anchored a2 above a1."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
'<div data-booth-mark="a2"></div><div data-booth-mark="a1"></div>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a1")
page.close()
assert [a for _, a in posts] == ["a2", "a1"], posts
def test_a_refused_ask_costs_only_itself_and_clears_nothing(browser, live):
"""The session withdrew a2 while the operator was answering. a1 and a3 are
still saved — one stale ask must not cost the rest — and the page does NOT
reload, so the pick he made for a2 is still on screen, and the page says
which one did not save."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
_choice(page, "a3", "no")
delete_mark(b, "a2")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a3-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a2"][value="yes"]')
same = page.evaluate("window.__same === 1")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "yes" and got["a3"]["choice"] == "no", got
assert "a2" not in got
assert same, "a failed batch reloaded the page and cleared what was entered"
assert kept, "the refused ask's pick was cleared"
assert "a2" in said and "2 of 3" in said, said
def test_an_authors_own_form_is_never_taken_over(browser, live):
"""The batch listens to the forms the SCRIPT mounted. An author's own form
on the same report — a search box, say — submits as the author wrote it,
even while the operator has unsent picks, and nothing of ours is sent."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
# an id with OUR prefix, so the mounted-root check is what excludes
# it, not the selector (kimi, hulda: the prefix alone hid the guard)
'<form id="bk-ask-form-theirs" action="/b/b/" method="get"><input name="q" value="x">'
'<button id="go">go</button></form>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
with page.expect_navigation():
page.click("#go")
url = page.url
page.close()
assert posts == [], posts
assert url.endswith("?q=x"), url
assert _answers(b) == {"a1": None, "a2": None}
# ---- the flight window (heid bug-hunt, 2026-09-27, 4 of 4 arms) --------------
#
# The batch reads every form at the press, but the page stays live for the
# whole flight. Every guard that protects that window survived its mutation,
# because no test pressed or edited inside one. These do: each POST's reply is
# held 700ms in the CLIENT, so the window is wide enough to act in on purpose.
_HOLD_POSTS = """
(function () {
var real = window.fetch;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if (o && o.method === 'POST') {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
});
}
return p;
};
})();
"""
def _open_held(browser, base, name, html, booth):
(booth / "index.html").write_text(html, encoding="utf-8")
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
return page
def test_a_press_inside_the_flight_never_fires_a_native_post(browser, live):
"""hulda: press a blank ask's button (a batch of the OTHER one starts), then
that other ask's own button. No other form is dirty any more from its point
of view, so it used to fall through to the browser — a native POST of an
answer already in flight, and a navigation racing the batch."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation(timeout=10000):
_press(page, "a1")
page.wait_for_timeout(150)
_press(page, "a2")
page.close()
assert posts == [("fetch", "a2")], posts
def test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press(browser, live):
"""All four arms: a successful batch reloaded with no carry, so a pick made
while it was in flight vanished. Now the page stays when anything changed
after the press, says so, and the next press sends ONLY that — the saved
answers are not sent again (their baseline moved to what the server took)."""
base, data = live
b = _asks(data / "b")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_press(page, "a1")
page.wait_for_timeout(150)
_choice(page, "a3", "no") # mid-flight
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a3"][value="no"]')
same = page.evaluate("window.__same === 1")
with page.expect_navigation(timeout=10000):
_press(page, "a3")
page.close()
assert same and kept, (same, kept)
assert "not saved yet" in said, said
assert [a for _, a in posts] == ["a1", "a2", "a3"], posts
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no", "a3": "no"}
def test_a_retry_after_a_refusal_sends_only_what_did_not_save(browser, live):
"""groa, hulda: after a partial failure the saved forms still read as dirty,
so the retry re-POSTed them and re-dated answers nobody changed."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
for mid in ("a1", "a2", "a3"):
_choice(page, mid, "yes")
delete_mark(b, "a2")
posts = _watch_posts(page)
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.evaluate("document.querySelector('#bk-ask-a3-submit .bk-ask-status').hidden = true")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.close()
assert [a for _, a in posts] == ["a1", "a2", "a3", "a2"], posts
def test_the_empty_status_line_stays_hidden_under_host_css(browser, live):
"""groa, regin: `p{display:block}` in the author's sheet outranks the UA's
own [hidden]; the embed restates it at class specificity."""
base, data = live
b = _asks(data / "b", ("a1",))
html = ("<!doctype html><title>r</title><style>p{display:block}</style>"
f"<body><h1>R</h1>{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
shown = page.evaluate(
"getComputedStyle(document.querySelector('#bk-ask-a1-submit .bk-ask-status')).display")
page.close()
assert shown == "none", shown
def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live):
"""hulda: the reload waited only on "nothing dirty". A refused form the
operator then set back to its first value reads clean — so the page
reloaded over a failure and never said it. A refusal blocks the reload on
its own."""
from booth.marks import answer_pick, delete_mark
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
answer_pick(b, "a2", "yes")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "no")
_choice(page, "a2", "no")
delete_mark(b, "a1")
_press(page, "a2")
page.wait_for_timeout(150)
_choice(page, "a1", "yes") # back to its first value, mid-flight
page.wait_for_timeout(2500)
same = page.evaluate("window.__same === 1")
shown = page.evaluate(
"!document.querySelector('#bk-ask-a2-submit .bk-ask-status').hidden")
page.close()
assert same, "the page reloaded over a refused POST"
assert shown, "the refusal was never said"
+307
View File
@@ -140,6 +140,7 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
page = browser.new_page()
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__same = 1")
(b / ".marks.json").write_text("{damaged")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
@@ -147,8 +148,12 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # past the reload
# ...and it DID reload, which the name promises and nothing asserted (heid
# bug-hunt 2026-09-27, hulda: deleting the reload survived this test)
reloaded = page.evaluate("window.__same !== 1")
page.close()
assert "Could not save in place" in said
assert reloaded, "the failure path never reloaded"
assert len(posts) == 1, f"re-POSTed: {posts}"
@@ -1499,3 +1504,305 @@ def test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan(browser, live):
assert g["gutter"] >= 15, ("the forced classic scrollbar is not in effect", g)
assert g["next_right"] <= g["client_right"] - 8 + 1, g
assert left == 800, left
# ---- one submit saves every changed pick on the page (2026-09-27) -------------
#
# The Booth-page half of the operator's report (the verbatim half is in
# test_embed_browser.py). Here an unsent pick SURVIVED another save — C3 carries
# dirty controls — but it was never SAVED, and pressing the submit of a blank
# pick was a 400, whose failure path reloads and wipes every one of them.
# Contract: r2_flow C3, "Several picks at once".
def _picks(b, ids=("a1", "a2", "a3")):
from booth.marks import declare_pick
for mid in ids:
declare_pick(b, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
def _chosen(b):
from booth.marks import marks_for
return {m.id: (m.answer or {}).get("choice") for m in marks_for(b) if m.shape == "pick"}
def test_one_submit_on_the_marks_page_saves_every_changed_pick(browser, live):
base, root = live
b = _set(root, 1)
_picks(b)
page = browser.new_page()
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.evaluate("window.__same = 1")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a2 input[type=radio][value="no"]')
page.check('#mark-a3 input[type=radio][value="yes"]')
page.locator("#mark-a3 .mark-submit").click()
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 3", timeout=10000)
same = page.evaluate("window.__same === 1")
page.close()
assert _chosen(b) == {"a1": "yes", "a2": "no", "a3": "yes"}
assert same, "the save reloaded instead of landing in place"
def test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it(browser, live):
"""On the lightbox, from the verdict aside. The pressed pick is blank: it
is not sent (a 400, whose failure reload wiped the rest), the other two
are, and the page stays put."""
base, root = live
b = _set(root, 3)
_picks(b)
posts = []
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__same = 1")
page.check('.verdict #mark-a1 input[type=radio][value="no"]')
page.check('.verdict #mark-a3 input[type=radio][value="no"]')
page.locator(".verdict #mark-a2 .mark-submit").click()
page.wait_for_function(
"document.querySelectorAll('.verdict .mark-pick.is-answered').length === 2", timeout=10000)
same = page.evaluate("window.__same === 1")
page.close()
assert _chosen(b) == {"a1": "no", "a2": None, "a3": "no"}
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
assert same
def test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing(browser, live):
"""a2's POST never reaches the server (the network drops it). a1 and a3
still land, nothing is sent twice, and — heid bug-hunt, 3 of 4 arms — the
page does NOT reload: the saved picks come back in place, a2's pick and a
half-typed note are still on screen, and the status line names a2."""
base, root = live
b = _set(root, 1)
_picks(b)
posts = []
page = browser.new_page()
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
page.route("**/b/g/answer", lambda route: route.abort()
if "ask=a2" in (route.request.post_data or "") else route.continue_())
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.evaluate("window.__same = 1")
for mid in ("a1", "a2", "a3"):
page.check(f'#mark-{mid} input[type=radio][value="yes"]')
page.locator(".mark-add textarea").fill("half a thought")
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_timeout(1500) # past where a reload would have been
same = page.evaluate("window.__same === 1")
kept = page.is_checked('#mark-a2 input[type=radio][value="yes"]')
draft = page.locator(".mark-add textarea").input_value()
page.close()
assert same, "a refused batch reloaded the page"
assert kept and draft == "half a thought", (kept, draft)
assert "a2" in said and "2 of 3" in said, said
assert _chosen(b) == {"a1": "yes", "a2": None, "a3": "yes"}
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a2", "ask=a3"], posts
# Every POST's reply held 700ms in the CLIENT, so a second press can land
# inside the flight on purpose rather than by luck.
_HOLD_POSTS = """
(function () {
var real = window.fetch;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if (o && o.method === 'POST') {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
});
}
return p;
};
})();
"""
def test_pressing_a_clean_pick_during_a_batch_sends_nothing(browser, live):
"""kimi: a blank pick pressed while a batch was in flight found every
dirty form busy, fell to the one-form path, POSTed a blank and got the 400
— 'Could not save in place' at the moment the save was succeeding, and a
reload. The batch in flight already covers it: the press is a no-op."""
base, root = live
b = _set(root, 1)
_picks(b)
posts = []
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.evaluate("window.__same = 1")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a3 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_timeout(150)
page.locator("#mark-a2 .mark-submit").click()
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
page.wait_for_timeout(1200)
status_hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
same = page.evaluate("window.__same === 1")
page.close()
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
assert status_hidden and same, (status_hidden, same)
# The first page refresh after a save held 1s, so a batch queues behind a flag
# and the flag's swap lands while the batch is still waiting its turn.
_HOLD_FIRST_REFRESH_AND_POSTS = _HOLD_FIRST_REFRESH + _HOLD_POSTS
def test_a_pick_in_flight_stays_in_flight_across_another_saves_swap(browser, live):
"""hulda: the in-flight mark lived on the DOM node, and a queued flag's
swap replaced the node with an unmarked copy — so a second press built a
second batch and POSTed the same answers twice. In flight is now keyed by
the form's identity, which survives a swap."""
base, root = live
b = _set(root, 2)
_picks(b, ("a1", "a2"))
posts = []
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.add_init_script(_HOLD_FIRST_REFRESH_AND_POSTS)
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.check('.verdict #mark-a1 input[type=radio][value="yes"]')
page.check('.verdict #mark-a2 input[type=radio][value="no"]')
page.locator(".verdict #mark-a1 .mark-submit").click()
page.wait_for_selector('figure.item.is-flagged[data-item="01.png"]', timeout=10000)
page.locator(".verdict #mark-a1 .mark-submit").click() # a fresh node, same form
page.wait_for_function(
"document.querySelectorAll('.verdict .mark-pick.is-answered').length === 2", timeout=15000)
page.wait_for_timeout(2500)
page.close()
asks = [p.split("&")[0] for p in posts if p.startswith("ask=")]
assert asks == ["ask=a1", "ask=a2"], posts
def test_a_later_save_clears_a_stale_not_saved_line(browser, live):
"""A partial batch's "Not saved: a2" stays on screen until something saves
again — and then it must go, or it reads as current after a2 has saved."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
drop = {"a2": True}
page = browser.new_page()
page.route("**/b/g/answer", lambda route: route.abort()
if drop["a2"] and "ask=a2" in (route.request.post_data or "")
else route.continue_())
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a2 input[type=radio][value="no"]')
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
drop["a2"] = False
page.locator("#mark-a2 .mark-submit").click()
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
page.close()
assert hidden, "the stale 'not saved' line outlived the save that fixed it"
assert _chosen(b) == {"a1": "yes", "a2": "no"}
# ---- round two of the flight window (heid bug-hunt, hulda, 2026-09-27) --------
def test_a_saved_notes_box_comes_back_empty(browser, live):
"""The form just sent comes back as the server renders it: a saved note's
box is EMPTY again. Nothing asserted it, so dropping the sent-form rule from
carry() survived its mutation (hulda) — and a box that kept its text would
post the same note twice on the next press."""
from booth.marks import marks_for
base, root = live
b = _set(root, 1)
page = browser.new_page()
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.locator(".mark-add textarea").fill("said once")
page.locator(".mark-add button").click()
page.wait_for_selector(".mark-note", timeout=10000)
left = page.locator(".mark-add textarea").input_value()
page.close()
assert left == "", f"the saved note's text came back as a draft: {left!r}"
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["said once"]
def test_a_change_to_a_sent_pick_during_the_flight_is_kept(browser, live):
"""hulda #1: carry() skipped EVERY control of a sent form, so a pick changed
after the press came back as the server's copy of the earlier one. A sent
form that changed since the press now carries like any unsent form."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a2 input[type=radio][value="yes"]')
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_timeout(150)
page.check('#mark-a1 input[type=radio][value="no"]') # after the press
page.wait_for_function(
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
page.wait_for_timeout(300)
showing = page.is_checked('#mark-a1 input[type=radio][value="no"]')
page.close()
assert _chosen(b) == {"a1": "yes", "a2": "yes"}
assert showing, "the change made after the press was replaced by the saved copy"
def test_a_queued_note_is_not_carried_back_as_a_draft(browser, live):
"""hulda #3: a note queued behind a flag. The flag's swap replaced the note
form, so the note's own refresh named a detached node, the sent-form rule
missed the live one, and the saved text came back as a draft — one more
press and it posted twice. Sent forms now match by identity."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.add_init_script(_HOLD_FIRST_REFRESH)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.locator(".verdict .mark-add textarea").fill("queued once")
page.locator(".verdict .mark-add button").click()
page.wait_for_selector(".verdict .mark-note", timeout=10000)
page.wait_for_timeout(1500)
left = page.locator(".verdict .mark-add textarea").input_value()
page.close()
assert left == "", f"the saved note came back as a draft: {left!r}"
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["queued once"]
def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live):
"""hulda #4, and heid's H1: every pick saved, then the page GET failed —
and the batch took C3's reload, taking an unsent note with it. A batch
never reloads: it says the page could not be refreshed."""
base, root = live
b = _set(root, 1)
_picks(b, ("a1", "a2"))
gate = {"on": False}
page = browser.new_page()
page.route("**/b/g/marks", lambda route: route.abort()
if gate["on"] and route.request.method == "GET" else route.continue_())
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
gate["on"] = True
page.evaluate("window.__same = 1")
page.check('#mark-a1 input[type=radio][value="yes"]')
page.check('#mark-a2 input[type=radio][value="no"]')
page.locator(".mark-add textarea").fill("not sent")
page.locator("#mark-a1 .mark-submit").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
page.wait_for_timeout(1500)
said = page.locator('[data-region="status"]').inner_text()
same = page.evaluate("window.__same === 1")
draft = page.locator(".mark-add textarea").input_value()
page.close()
assert _chosen(b) == {"a1": "yes", "a2": "no"}
assert same and draft == "not sent", (same, draft)
assert "reload" in said.lower(), said