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.
110 lines
4.6 KiB
TOML
110 lines
4.6 KiB
TOML
# 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;'''
|