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