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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user