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:
@@ -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'
|
||||
|
||||
@@ -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;'''
|
||||
@@ -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 = ''''''
|
||||
@@ -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"
|
||||
|
||||
@@ -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