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