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
+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"