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
+109
View File
@@ -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;'''