diff --git a/booth/static/embed.js b/booth/static/embed.js index 15d2f36..2d24f96 100644 --- a/booth/static/embed.js +++ b/booth/static/embed.js @@ -100,6 +100,10 @@ ".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}", ".bk-ask-was b{opacity:1}", ".bk-ask-err{color:var(--bk-err);font-size:.86rem}", + /* [hidden] restated at class specificity: a host rule as plain as + `p{display:block}` outranks the UA's own [hidden] and would show it. */ + ".bk-ask-status{margin:.6rem 0 0;font-size:.86rem;color:var(--bk-err)}", + ".bk-ask-status[hidden]{display:none}", "@media print{.bk-ask{break-inside:avoid}}" ].join(""); @@ -368,6 +372,133 @@ if (e.key === "booth.theme" || e.key === null) bkTheme(); }); + /* ONE SUBMIT SAVES EVERY ASK ON THE PAGE (2026-09-27; U3 contract, + "Submitting several asks at once"). One pick is one
is one POST, + and that POST's 303 reload wiped every pick made in the others: the + operator answered top to bottom, pressed the last button, and lost the + rest (`auk-audition`, 15:02:23 in the access log). + + A submit on one of OUR forms, while ANOTHER of ours holds input the + operator changed, sends every changed ("dirty") form of ours: one POST + each, to its own action, with `Accept: application/json` for the route's + 204 (r2 C3), one after another in DOCUMENT ORDER of the forms. A form + nobody touched is not sent - re-sending re-dates an answer nobody gave, and + a blank one is refused - and that includes the pressed one. A refused form + stops nothing. Each form the server took becomes its own new baseline, so + it is never sent again unless it changes again. + + Then the page reloads - so what shows is the server's record - ONLY if + nothing is left unsaved: no refusal, and nothing of ours changed while + the batch was in flight. Otherwise NO reload: the pressed form's status line says + what did not save, and everything he entered stays on the page. The page + stays live during the flight, which is why that second condition exists + (heid bug-hunt, 4 of 4 arms). A press during the flight is ignored, never + handed to the browser; nothing is re-sent without a fresh press. + + With no batch in flight and no OTHER dirty form, this does nothing and + the browser submits: the plain POST and 303 it always was. */ + var sending = false; + + function serial(form) { + return new URLSearchParams(new FormData(form)).toString(); + } + + function dirty(form) { + /* Against what the server last TOOK from this page, once it has taken + something (`__bkSaved`, set per form on its 204). Before that, against + the server-rendered default: `form.elements` includes every control + bound by `form=`, wherever it sits in the document. */ + if (form.__bkSaved !== undefined) return serial(form) !== form.__bkSaved; + var els = form.elements; + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.type === "radio" || el.type === "checkbox") { + if (el.checked !== el.defaultChecked) return true; + } else if (el.tagName === "TEXTAREA" || el.type === "text") { + if (el.value !== el.defaultValue) return true; + } + } + return false; + } + + function ourForms() { + /* Document order (querySelectorAll), and only forms inside something this + script mounted: an author's own form is theirs, never ours to send. */ + var all = document.querySelectorAll('form[id^="bk-ask-form-"]'), out = []; + for (var i = 0; i < all.length; i++) { + for (var j = 0; j < ours.length; j++) { + if (ours[j].contains(all[i])) { out.push(all[i]); break; } + } + } + return out; + } + + function askOf(form) { + var els = form.elements; + for (var i = 0; i < els.length; i++) { + if (els[i].name === "ask") return els[i].value; + } + return "?"; + } + + function send(form, body) { + /* Resolves to null when saved, or to why it was not. NEVER rejects, so one + refusal cannot stop the chain behind it. */ + return fetch(form.action, { + method: "POST", body: body, credentials: "same-origin", + headers: { "Accept": "application/json" } + }).then(function (r) { + if (r.status === 204) return null; + return r.json().then(function (j) { return (j && j.detail) || "status " + r.status; }, + function () { return "status " + r.status; }); + }, function () { return "the Booth did not answer"; }); + } + + document.addEventListener("submit", function (ev) { + var form = ev.target; + if (ev.defaultPrevented || !window.fetch || !window.URLSearchParams || !window.FormData) return; + var forms = ourForms(); + if (forms.indexOf(form) < 0) return; + /* In flight FIRST: a press on a form with no other dirty form beside it + would otherwise fall through to the browser, a native POST racing the + batch (heid bug-hunt, hulda). */ + if (sending) { ev.preventDefault(); return; } + var others = forms.some(function (f) { return f !== form && dirty(f); }); + if (!others) return; // the browser's own POST and 303 + ev.preventDefault(); + sending = true; + var batch = forms.filter(dirty); + // every form's fields read NOW, at the press + var bodies = batch.map(function (f) { return new URLSearchParams(new FormData(f)); }); + var failed = [], chain = Promise.resolve(); + batch.forEach(function (f, n) { + chain = chain.then(function () { + return send(f, bodies[n]).then(function (why) { + if (why === null) f.__bkSaved = bodies[n].toString(); + else failed.push(askOf(f) + " (" + why + ")"); + }); + }); + }); + chain.then(function () { + sending = false; // before anything here can throw + /* A refusal blocks the reload ON ITS OWN: a refused form the operator + then set back to its first value reads clean, and "nothing dirty" + alone reloaded over the failure without a word (heid bug-hunt, + hulda). Otherwise anything dirty was touched mid-flight. */ + var changed = forms.some(dirty); + if (!failed.length && !changed) { location.reload(); return; } + var saved = batch.length - failed.length; + var st = form.parentNode && form.parentNode.querySelector(".bk-ask-status"); + if (!st) return; + st.textContent = failed.length + ? "Saved " + saved + " of " + batch.length + ". Not saved: " + failed.join("; ") + + ". Nothing you entered was cleared; reload to see what was saved." + : "Saved " + saved + " of " + batch.length + ". You changed an answer while " + + "that was saving, and it is not saved yet: press Submit again to save it."; + st.hidden = false; + }); + }); + function start() { var name = boothName(); if (!name || !document.body) return; diff --git a/booth/templates/_ask_inline.html b/booth/templates/_ask_inline.html index 08bbd45..6e41b85 100644 --- a/booth/templates/_ask_inline.html +++ b/booth/templates/_ask_inline.html @@ -62,6 +62,9 @@ placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }} {% endif %} + {# Empty and hidden: where embed.js says which asks a several-at-once submit + could not save. The script only sets its text; it builds no markup. #} + {% endmacro %} diff --git a/booth/templates/base.html b/booth/templates/base.html index 1a34f85..154efd6 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -1064,6 +1064,12 @@ var st = document.querySelector('[data-region="status"]'); if (st) { st.textContent = text; st.hidden = false; } } + /* A new save clears the last one's words: a partial batch's "not saved" + must not outlive the save that fixes it. */ + function quiet() { + var st = document.querySelector('[data-region="status"]'); + if (st) { st.textContent = ''; st.hidden = true; } + } /* A form's IDENTITY: its action plus the hidden fields that say what it is about (which pick, which item, which mark). Stable across renders, where a position inside a region is not — a form that appears or vanishes @@ -1103,9 +1109,12 @@ must not (a revealed blur, a closed doc, a disclosure the reader opened or closed); and every DIRTY control — a half-typed note, an edited one, a radio picked and not yet sent. All matched by IDENTITY, - never by position. The form just sent is the exception: its fields and - its disclosure come back as the server rendered them. */ - function carry(oldEl, newEl, sent) { + never by position. The forms just sent are the exception: their fields + and their disclosures come back as the server rendered them — unless the + form changed after its press, when it carries like any unsent form + (heid bug-hunt, hulda: a pick changed mid-flight came back as the saved + copy of the earlier one). See isSent. */ + function carry(oldEl, newEl, isSent) { var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]')); newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) { for (var i = 0; i < olds.length; i++) { @@ -1123,7 +1132,7 @@ var oldDetails = detailsMap(oldEl); Object.keys(oldDetails).forEach(function (k) { var d = oldDetails[k]; - if (sent && d.contains(sent)) return; + if ([].some.call(d.querySelectorAll('form'), isSent)) return; if (freshDetails[k]) freshDetails[k].open = d.open; }); var freshFields = {}; @@ -1131,7 +1140,7 @@ if (el.type !== 'hidden') freshFields[fieldKey(el)] = el; }); oldEl.querySelectorAll('textarea, input').forEach(function (el) { - if (el.type === 'hidden' || (sent && el.form === sent)) return; + if (el.type === 'hidden' || isSent(el.form)) return; var t = freshFields[fieldKey(el)]; if (!t) return; if (el.type === 'radio' || el.type === 'checkbox') { @@ -1145,7 +1154,7 @@ beyond a tile falling out of a filter — a panel region that appeared or vanished — or when the page has no region to swap at all. Then only a reload tells the truth. */ - function swap(html, sent) { + function swap(html, isSent) { var fresh = new DOMParser().parseFromString(html, 'text/html'); var freshById = {}, liveIds = {}; fresh.querySelectorAll('[data-region]').forEach(function (c) { @@ -1164,7 +1173,7 @@ var next = freshById[id]; if (next) { var node = document.importNode(next, true); - carry(el, node, sent); + carry(el, node, isSent); el.replaceWith(node); swapped++; } else if (id.indexOf('item-') === 0) { @@ -1184,37 +1193,162 @@ /* SERIALIZED: each save runs its POST, its re-fetch and its swap before the next begins, so an older snapshot can never land after a newer one. A form already queued or in flight ignores another submit — a - double-click writes one note, not two. */ + double-click writes one note, not two. IN FLIGHT is keyed by the form's + IDENTITY (formKey), never marked on the node: a queued save's swap + replaces the node with a fresh copy, and a mark on the old node would + let a second press send the same answer again (heid bug-hunt, hulda). */ var queue = Promise.resolve(); - function run(form, data) { + var pending = {}; + /* The one place "which form is in flight" gets its identity. */ + function flightKey(f) { return formKey(f); } + function post(form, data) { return fetch(form.action, { method: 'POST', body: new URLSearchParams(data), headers: {'Accept': 'application/json'}, credentials: 'same-origin' }).then(function (r) { if (r.status !== 204) throw new Error('status ' + r.status); - return fetch(window.location.href, {headers: {'Accept': 'text/html'}, credentials: 'same-origin'}); - }).then(function (r) { - if (!r.ok) throw new Error('status ' + r.status); - return r.text(); - }).then(function (html) { - if (!swap(html, form)) window.location.reload(); - }).catch(function () { - /* Said, then reloaded after a beat, so the words are readable rather - than a flash before the page goes. */ - say('Could not save in place — reloading to show what was saved.'); - setTimeout(function () { window.location.reload(); }, 900); + }); + } + function serial(f) { + return new URLSearchParams(new FormData(f)).toString(); + } + /* What was sent, as the swap needs it: each form by IDENTITY (flightKey), + with its fields as they stood at the press. A live form is "just sent" + only if it is one of those AND unchanged since — so a queued save whose + node an earlier swap replaced is still recognised (hulda: its saved + note came back as a draft), and a form edited mid-flight is not. */ + function sentSet(recs) { + return function (f) { + if (!f) return false; + var k = flightKey(f); + for (var i = 0; i < recs.length; i++) { + if (recs[i].key === k && recs[i].snap === serial(f)) return true; + } + return false; + }; + } + /* Resolves true when the fresh page was placed. `keep` (a batch): never + reload — a structural change or a failed GET is said, not acted on, + because a reload would take every unsent draft with it. */ + function refresh(recs, keep) { + return fetch(window.location.href, {headers: {'Accept': 'text/html'}, credentials: 'same-origin'}) + .then(function (r) { + if (!r.ok) throw new Error('status ' + r.status); + return r.text(); + }).then(function (html) { + if (swap(html, sentSet(recs))) return true; + if (!keep) window.location.reload(); + return false; + }); + } + function fail() { + /* Said, then reloaded after a beat, so the words are readable rather + than a flash before the page goes. */ + say('Could not save in place — reloading to show what was saved.'); + setTimeout(function () { window.location.reload(); }, 900); + } + function run(form, data, snap) { + return post(form, data).then(function () { + return refresh([{key: flightKey(form), snap: snap}]); + }).catch(fail); + } + /* SEVERAL PICKS AT ONCE (C3 step 3a, 2026-09-27). One pick is one form + is one POST, so a page holding several picks saved only the one whose + button was pressed. The others' unsent radios SURVIVED the swap (carry, + above) but were never saved — and pressing a BLANK pick's button was a + 400, whose failure reload wiped them all. The operator's report: one + submit must save every answer he filled in. + + A pick form carries a hidden `ask`, the field formKey reads. DIRTY is + what carry() measures: a control that differs from its server-rendered + default. */ + function isPick(f) { + return !!f.querySelector('input[type=hidden][name="ask"]'); + } + function dirty(f) { + return [].some.call(f.elements, function (el) { + if (el.type === 'radio' || el.type === 'checkbox') return el.checked !== el.defaultChecked; + if (el.tagName === 'TEXTAREA' || el.type === 'text') return el.value !== el.defaultValue; + return false; + }); + } + /* What a submit on pick form `form` must send: every dirty pick form of + the same action NOT already in flight, in DOCUMENT ORDER — `form` only + if it is dirty, since re-sending an untouched answer re-dates it and a + blank one is refused. Null when no OTHER pick form is dirty, which + leaves the one-form path below exactly as it was. A form in flight + still COUNTS as another dirty form, so a press on a clean pick during a + batch is an empty batch — a no-op — and never the blank one-form POST + whose 400 would reload the page mid-save (heid bug-hunt, kimi). */ + function pickBatch(form) { + if (!isPick(form)) return null; + var action = form.getAttribute('action'); + var picks = [].filter.call(document.querySelectorAll('form[data-inplace]'), function (f) { + return f.getAttribute('action') === action && isPick(f); + }); + var others = picks.some(function (f) { return f !== form && dirty(f); }); + return others ? picks.filter(function (f) { return dirty(f) && !pending[flightKey(f)]; }) : null; + } + function askOf(f) { + var h = f.querySelector('input[type=hidden][name="ask"]'); + return h ? h.value : '?'; + } + /* One POST per form, in turn, a refusal stopping none of the rest (one + stale pick must not cost the others). Then ONE refresh in place, in + which only the forms the server TOOK count as sent, so everything else + carries by identity — a refused pick's input and any draft included. + A batch NEVER reloads the page (heid bug-hunt, 3 of 4 arms, then + hulda): a refusal, a failed refresh or a changed page is SAID, in the + status line, and the page stays. Never a re-POST. */ + function runAll(forms, datas, snaps) { + var saved = [], refused = [], chain = Promise.resolve(); + forms.forEach(function (f, i) { + chain = chain.then(function () { + return post(f, datas[i]).then(function () { + saved.push({key: flightKey(f), snap: snaps[i]}); + }, function (e) { refused.push(askOf(f) + ' (' + e.message + ')'); }); + }); + }); + return chain.then(function () { + var told = refused.length + ? 'Saved ' + saved.length + ' of ' + forms.length + '. Not saved: ' + + refused.join('; ') + '. Nothing else you entered was cleared.' + : ''; + var shown = saved.length ? refresh(saved, true) : Promise.resolve(true); + return shown.then(function (placed) { + if (!placed) say((told || 'Saved.') + ' The page changed meanwhile; reload to see it.'); + else if (told) say(told); + }, function () { + say((told || 'Saved ' + saved.length + ' of ' + forms.length + '.') + + ' Could not refresh the page; reload to see what was saved.'); + }); }); } document.addEventListener('submit', function (ev) { var form = ev.target; if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return; ev.preventDefault(); - if (form.__busy) return; - form.__busy = true; + if (pending[flightKey(form)]) return; + var batch = pickBatch(form); + if (batch) { + if (!batch.length) return; /* everything dirty is already in flight */ + quiet(); + var keys = batch.map(flightKey); + var datas = batch.map(function (f) { return new FormData(f); }); /* read NOW */ + var snaps = batch.map(serial); + keys.forEach(function (k) { pending[k] = true; }); + queue = queue.then(function () { return runAll(batch, datas, snaps); }) + .then(function () { keys.forEach(function (k) { delete pending[k]; }); }); + return; + } + var key = flightKey(form); + pending[key] = true; + quiet(); + var snap = serial(form); /* the form's own fields, before the submitter */ var data = new FormData(form); if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value); - queue = queue.then(function () { return run(form, data); }) - .then(function () { form.__busy = false; }); + queue = queue.then(function () { return run(form, data, snap); }) + .then(function () { delete pending[key]; }); }); })(); diff --git a/docs/contracts/r2_flow.contract.md b/docs/contracts/r2_flow.contract.md index c5c0305..593d600 100644 --- a/docs/contracts/r2_flow.contract.md +++ b/docs/contracts/r2_flow.contract.md @@ -215,11 +215,77 @@ today's zoom flag form carries no `back`, so it lands on the gallery. by the pick or form it holds. A flag that adds a tray row above a draft must not move the draft into the wrong box. The form just sent is the exception: its fields come back as the server rendered them, and its - disclosure comes back folded. + disclosure comes back folded — UNLESS it changed after the press, when it + carries like any unsent form (amended 2026-09-27; a pick changed + mid-flight came back as the saved copy of the earlier one). "Just sent" + is the form's IDENTITY plus its fields as they stood at the press, never + the DOM node: a queued save whose node an earlier swap replaced is still + recognised, where a node test missed it and carried a saved note's text + back as a draft. 3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap before the next begins, so an older snapshot never lands after a newer one (three quick flags show three flags). A form already queued or in flight - ignores another submit: a double-click writes one note, not two. + ignores another submit: a double-click writes one note, not two. "In flight" + is keyed by the form's IDENTITY (the same action + hidden-field key the + carry uses), never marked on the DOM node, because a queued save's swap + replaces the node with a fresh copy (amended 2026-09-27). +3a. **Several picks at once (amended 2026-09-27).** A pick form is a + `data-inplace` form carrying a hidden `ask` field. It is **dirty** when any + control in it differs from its server-rendered default (`checked` vs + `defaultChecked`, `value` vs `defaultValue`). A submit on a pick form while + ANOTHER pick form of the same action is dirty sends every dirty pick form + NOT already in flight — the pressed one only if it is dirty. + - A form in flight still counts as "another dirty form", so a press on a + clean pick during a batch is an empty batch: a no-op, never the blank + one-form POST whose 400 would take step 4 mid-save. + - One POST per form, to its own action, with its own fields read at the + moment of the press, one after another in DOCUMENT ORDER of the forms. A + refused POST does not stop the ones after it. + - None refused: ONE GET and ONE swap, in which every form sent counts as + "the form just sent": its fields come back as the server rendered them, + its disclosure folded. + - Any refused: ONE GET and ONE swap in which only the forms the server + TOOK count as sent, so everything else carries by identity — the refused + pick's own input and any draft on the page included — then the status + line says how many saved and names each pick that did not, with the + reason. (Nothing saved at all: no GET, just the words.) A pick withdrawn + under the page has no form in the fresh page to carry into; the reason + says so. This is the same rule as the verbatim half: a refusal never + clears what the operator entered. + - **A batch never reloads.** Where step 2 would reload — a failed GET, or a + fresh page whose structure changed — a batch says so in the status line + ("reload to see it") and keeps the page, because a reload would take + every unsent draft with it. Step 4's say-and-reload stays the one-form + path's alone. + - The status line is cleared when the next save starts, so a "not saved" + never outlives the save that fixes it. + - With no other dirty pick form, the submit takes steps 1–3 exactly as + before. + + Why: C3 already CARRIED an unsent pick across another save, so it survived + — but it was never SAVED, and pressing the submit of a BLANK pick got a + 400, whose failure reload wiped every one. The operator's report + (2026-09-27, relayed by infra-ops): one submit on a page must save every + answer he filled in. The verbatim half of the same fix is U3's "Submitting + several asks at once"; the two surfaces share the dirty rule, the order and + the refusal rule, and differ only where their machinery does (this one + swaps in place; the verbatim page reloads when nothing is left unsaved). + *Falsifiable* (`tests/mutations/r2_submit_all.toml`): ignore the other pick + forms and `test_one_submit_on_the_marks_page_saves_every_changed_pick` + fails; send the pressed form even when blank and + `test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it` fails; + stop at the first refusal, reload on one, or count a refused pick as sent, + and `test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing` + fails; stop counting a form in flight as dirty and + `test_pressing_a_clean_pick_during_a_batch_sends_nothing` fails; key "in + flight" on the DOM node and + `test_a_pick_in_flight_stays_in_flight_across_another_saves_swap` fails; + leave the status line up and `test_a_later_save_clears_a_stale_not_saved_line` + fails; reload when the refresh fails and + `test_a_batch_whose_refresh_fails_keeps_the_page` fails; count a sent form + as sent after it changed and + `test_a_change_to_a_sent_pick_during_the_flight_is_kept` fails; count no + form as sent and `test_a_saved_notes_box_comes_back_empty` fails. 4. **The script never re-POSTs.** A retry after a lost response would re-apply the judgment: a duplicate note, or a re-dated answer. - On a non-204 HTTP response, or a network failure, it writes a fixed diff --git a/docs/contracts/u3_declared_embed_seam.contract.md b/docs/contracts/u3_declared_embed_seam.contract.md index 44f49cb..8d0e7c9 100644 --- a/docs/contracts/u3_declared_embed_seam.contract.md +++ b/docs/contracts/u3_declared_embed_seam.contract.md @@ -235,6 +235,86 @@ It is three lines, it costs nothing, and the sensitivity floor of that probe is guards against is a form the operator fills in whose controls reach no form, so the button does nothing. +## Submitting several asks at once (amended 2026-09-27) + +**The defect.** One pick is one `` is one POST to `/answer`, and that +POST 303s back to the page. On a report carrying several picks, a submit sent +exactly ONE of them, and the reload that followed wiped every pick the operator +had made in the others. His report, relayed by infra-ops: *"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 (three single-question picks, +no anchors, so all three in the tail) before any code: the access log shows one +POST at 15:02:23 that saved the LAST pick on the page, the reload, then a 400 +four seconds later — the submit of a pick the reload had just blanked — and the +other two re-answered one at a time. **The server is not the defect**: every +POST did exactly what `/answer` promises. The page gave him one button per +form and no way to send them together. + +**The rule.** embed.js listens for `submit` on the forms IT mounted (never an +author's form). A form is **dirty** when any control it owns — `form.elements`, +which includes every control bound to it by `form=` wherever it sits — differs +from its server-rendered default: a radio or checkbox whose `checked` differs +from `defaultChecked`, a textarea or text input whose `value` differs from +`defaultValue`. + +``` +submit on one of our forms F: + a batch is in flight -> preventDefault; nothing else (never the + browser's POST racing the batch) + no OTHER of our forms is dirty -> do nothing; the browser's own POST and 303, + exactly as before this amendment + otherwise -> preventDefault, and send EVERY dirty form of + ours, F included only if F is dirty + send: one POST per form, to that form's own action, carrying that form's own + FormData read AT THE PRESS, with `Accept: application/json` (the + route's 204, r2 C3), one after another, in DOCUMENT ORDER of the + elements. A refused form does not stop the ones after it. A form the + server took (204) gets a new baseline: what it sent. From then on it is + dirty only if it differs from THAT. + then: no refusal AND nothing -> reload the page (a GET), so what shows is + of ours is dirty the server's record + otherwise -> NO reload. The pressed form's submit block + (a refusal, or a change says how many saved and what did not, with + made during the flight) the server's reason, and everything the + operator entered stays on the page. + A refusal blocks the reload ON ITS OWN: a refused form set back to its + first value reads clean, and "nothing dirty" alone reloaded over it. +``` + +Five consequences, each deliberate: + +- **A blank pick is skipped, never refused.** A pick with nothing entered is not + dirty and is not sent, so pressing its button no longer produces the 400 page + the log shows. Blanks stay legal, as `build_answer` has held since 2026-09-09. +- **A pick nobody touched is not re-sent — including the one whose button was + pressed, and including one this page already saved.** Re-sending an answer + re-dates it, and a reading session would see a fresh answer that nobody gave. + The moved baseline is what keeps a retry after a partial refusal to exactly + the picks that did not save; it also means correcting a saved pick back to + its first-rendered value counts as a change and is sent. +- **One refused pick costs only itself.** A pick withdrawn or re-declared while + the page was open is refused (404 / 400); the rest are saved regardless. This + is the partial-answer ruling's reasoning applied one level up: refusing + everything because one was stale throws away the ones that were made. +- **The page is reloaded only when nothing would be lost by it.** The page + stays live while the batch is in flight; a pick made or a note typed in that + window is unsaved input, and a reload would clear it — the exact loss this + amendment exists to stop. So the reload waits on "every POST succeeded" AND + "nothing of ours is dirty" — each on its own. The saved picks' tags stay stale until he + reloads, and the message says so. Nothing is ever re-sent without a fresh + press; a press after a lost response may re-send (and re-date) a pick that + did land, which is the price of never retrying on our own. +- **A press during the flight is ignored.** Checked before anything else, so a + press on a form with no other dirty form beside it cannot fall through to the + browser's POST while the batch runs. + +**What it does not change.** `/answer`, its fields, its 204 and its 303 are +untouched: the server has no batch endpoint and no new request shape. A page +whose only dirty form is the pressed one gets the plain form submission, +byte-identical to before. The status line is server-rendered, empty and +`hidden` in the `submit` macro; the script only sets its text, so embed.js +still renders no markup of an ask. + **Step 5 deletes an element.** Today `inject_asks` injects `` before the first fragment of each pick so the chip has somewhere to jump. The fragments already carry ids; document order in a live DOM is directly queryable; @@ -336,6 +416,32 @@ rather than strict. and `test_partially_marked_page_still_shows_every_question` fails in the browser with 2 of 4 radio groups present. +**INV-8 — One submit saves every pick on the page the operator changed +(amended 2026-09-27).** Per "Submitting several asks at once": every dirty form +of ours is sent, in document order; a clean one never is, nor a saved one +again; a refused one stops nothing; the page reloads only when nothing was +refused and nothing of ours is left unsaved; a press during the flight is ignored; and with no other dirty +form the submit is the browser's own. +*Falsifiable*, one change per clause, each in `tests/mutations/u3_submit_all.toml`: +send only the pressed form and +`test_one_submit_saves_every_answered_ask_on_the_page` fails; send the pressed +form whether or not it is dirty and `test_a_blank_ask_is_skipped_never_refused` +fails; send every form regardless and `test_an_ask_nobody_touched_is_not_re_sent` +fails; intercept a lone dirty form and `test_one_changed_ask_still_submits_as_a_plain_form` +fails; send in reverse and `test_the_asks_are_sent_in_document_order` fails; +stop at the first refusal, reload on one, or never show the status line, and +`test_a_refused_ask_costs_only_itself_and_clears_nothing` fails; listen to every +form on the page, or trust our id prefix without the mounted-root check, and +`test_an_authors_own_form_is_never_taken_over` fails; let a press in flight fall +through and `test_a_press_inside_the_flight_never_fires_a_native_post` fails; +reload when every POST succeeded regardless of what changed meanwhile and +`test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press` fails; +leave a saved form's baseline where it was and +`test_a_retry_after_a_refusal_sends_only_what_did_not_save` fails; drop the +class-level `[hidden]` rule and `test_the_empty_status_line_stays_hidden_under_host_css` +fails; let "nothing dirty" alone decide the reload and +`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails. + ## Out of scope (deferred or never) Named so a reviewer does not read them as drift. diff --git a/persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md b/persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md new file mode 100644 index 0000000..a25f50a --- /dev/null +++ b/persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md @@ -0,0 +1,102 @@ +# 2026-09-27 — One submit saves every ask on the page + +**The report.** Prime to infra-ops, relayed to booth-dev (althing thread +`01M3JED397G1SZH7580PCXNVVA`): "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 live data before any code, and it matched to the second.** +`auk-audition`: three single-question picks, no anchors, so all three in the +embed tail in `(created, id)` order. The access log: one POST at 15:02:23 (303) +that saved `auk-emotion`, the LAST pick on the page; the reload; a POST at +15:02:27 that 400'd (the submit of a pick the reload had just blanked — the +browser showed a raw `{"detail": ...}` page); then `auk-clone` at 15:04:45 and +`auk-events` at 15:05:02, answered one at a time. infra-ops' reading of the +code was right. `vastblue-site-visit-2026-09-29` carries 14 picks and would +have hit it next. + +**The cause is not the server.** One pick = one `` = one POST to +`/answer`, and every POST did what `/answer` promises. The page offered one +button per form and no way to send them together. Two surfaces, two machineries: + +- **Verbatim report (embed.js).** Plain form POST + 303 reload. The reload + wiped every unsent pick. +- **Booth pages (base.html's in-place script: marks page, lightbox verdict + aside, review rail).** R2 C3 already CARRIED unsent picks across a swap, so + they survived — but were never SAVED. And pressing a blank pick's submit was + a 400, whose failure path reloads and wipes them all. + +**The shape (booth-dev's call; infra-ops said "the shape is your call").** +Client-side, both surfaces, no server change: a submit on a pick form, while +ANOTHER pick form on the page is dirty (a control differs from its +server-rendered default), sends every dirty pick form — the pressed one only if +dirty — one POST each to the unchanged `/answer` with `Accept: +application/json` (the 204), serially in document order, a refusal stopping +none of the rest. With no other dirty form, nothing changes: the browser's own +POST (verbatim) or C3's one-form path (Booth pages). + +- **Rejected: a server batch endpoint + one page-level form.** It would also + fix no-JS, but needs ask-scoped field names, a single `` element placed + by embed.js, and rewires `formKey` identity on the Booth pages (one form + holding many `ask` fields). Large blast radius for a no-JS verbatim path that + does not exist (U3's named cost). Atomic all-or-nothing was also the WRONG + semantics: one stale pick would refuse the rest — the 2026-09-09 + partial-answer ruling's reasoning, one level up. +- **Untouched picks are not re-sent**, the pressed one included: re-sending + re-dates an answer nobody gave. +- **A refusal never clears what was entered, on either surface** (the first + cut had the Booth batch take C3's say-and-reload path; heid's panel caught + it, see below). Verbatim: no reload while anything of ours is dirty; the + pressed form's server-rendered, empty, hidden `.bk-ask-status` line says what + did not save. Booth pages: refresh in place with only the forms the server + took counted as sent, so the refused pick and any draft carry by identity; + the status region names what did not save. C3's ONE-form failure path + (step 4, say and reload) is untouched — not this change's surface, and it + still loses drafts on a refusal (named, not fixed). + +**The heid bug-hunt panel (4/4 arms, thread `01M3JFDM1AWT2TCKS6E9NJM9G4`) was +the gate that paid.** All four landed on the same blind spot: the batch reads +every form AT THE PRESS but the page stays live through the flight, and every +guard protecting that window (`sending`, `__busy`, `held`) SURVIVED mutation, +because no test pressed or edited inside a flight. Six of its rows reproduced +RED in a browser before any fix: a successful embed batch reloading away a pick +made mid-flight (S1); the Booth batch's refusal reload (S2); saved forms staying +dirty so a retry re-dated them (S3); in-flight marked on DOM nodes a queued +swap replaced (S5 — now keyed by `formKey` identity via `flightKey`); a press +in flight falling through to a native POST (embed) or a blank 400 (Booth) (S6). +The trick that made them testable: hold every POST's reply 700ms in the CLIENT +(`_HOLD_POSTS`, the `_HOLD_FIRST_REFRESH` pattern) so the window is wide enough +to act in on purpose. S7 (no ask dedupe) rejected as unreachable; S8–S11 +accepted with reasons in the fold reply (`01M3JHYKA0GSJG4F836J1Z6TJS`). +**Lesson: a feature that opens an async window needs a test that acts inside +it; a green suite of single presses says nothing about the window.** + +**Round two: a single cold Hulda arm on the FOLDED tree found six more** +(thread `01M3JHYKCPXFA34XMRCEW4P3DJ`), all in how the fold's own rules +interacted — and Heid's primed second voice, reading only the fold's delta, +cleared two of them wrongly and retracted. Four reproduced RED: a sent pick +changed mid-flight came back as the saved copy (#1); a note queued behind a +flag carried back as a draft because "just sent" was a DOM-node test (#3); +a batch whose page GET failed reloaded drafts away (#4); the embed reloaded +over a refusal the operator had set back to its first value (#6). Fix: +"just sent" = `flightKey` identity + the form's serialization at the press +(`sentSet`), a batch never reloads, and a refusal blocks the embed reload on +its own. #2 was a message fix (a withdrawn pick's input has nowhere to go); +#5 accepted (needs a refused POST AND a failed GET; costs a re-date of +identical content). **A cold read of the whole diff beat a primed read of the +delta** — worth remembering before scoping a re-review to "just the fold". + +**Contracts amended in the same commit:** U3 "Submitting several asks at once" ++ INV-8; R2 C3 steps 3 and 3a. **Mutation tables:** `tests/mutations/u3_submit_all.toml` +(15 rows) and `tests/mutations/r2_submit_all.toml` (11 rows), all proved; the +r2_flow row anchored on `form.__busy` moved to `pending[flightKey(form)]`. +**Tests:** 23 new browser tests plus two tightened (the author-form test now +wears our id prefix; the one-form failure test now asserts the reload it names). +Suite 928 → 951. +The `[a3]` case reproduced the live log exactly (only a3 saved). + +**Not done, named:** no no-JS batch on the Booth pages (each plain form still +saves one pick with scripts off — the no-JS path is unchanged, as asked); a +verbatim batch that keeps the page leaves the saved picks' tags stale until a +reload, and the message says so; C3's one-form refusal still reloads drafts +away; Hulda #5 (above) accepted. diff --git a/persistent-memory.md b/persistent-memory.md index 26937d8..e0184a6 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -1,6 +1,6 @@ # Persistent memory — booth -_Last updated: 2026-09-25_ +_Last updated: 2026-09-27_ > **Always check for `/tmp/booth-dev-handoff.md`** — if it exists and its > `Written:` stamp is under 8 hours old, read it (it carries the in-flight @@ -17,12 +17,14 @@ loop it turned out to actually be. ## Current state / in-flight -_As of 2026-09-25:_ +_As of 2026-09-27:_ -- 🟢 **NOTHING IS IN FLIGHT** (shutdown snapshot, 2026-09-25). The tree is - clean, no branch or worktree is open, and no peer is waiting on booth-dev. - main is ONE memory commit ahead of origin (this snapshot), unpushed: the - push is the operator's call. +- ✅ **ONE SUBMIT SAVES EVERY ASK ON A PAGE** (2026-09-27, Prime's bug via + infra-ops, thread `01M3JED397G1SZH7580PCXNVVA`). Client-side on both + surfaces (embed.js, base.html's in-place script), no server change; a + refusal or a mid-flight edit never clears input. Local commits on main, + UNPUSHED (push is the operator's call); service restarted to make it live. + → `persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md` - ✅ **r3 COMPARE IS LIVE AND PUSHED** (operator: "merge r3 once the mutation check is clean", then "merge and push", 2026-09-24). origin/main is the r3 arc's tip: r3 (`f8d136a`), design-dev's race fix (`d54bb04`: one compare ring @@ -85,6 +87,7 @@ _As of 2026-09-25:_ ## Recent decisions +- `[2026-09-27]` ✅ **One submit saves every ask on the page; the heid panel found the async window** — READ BEFORE TOUCHING THE SUBMIT PATH OF embed.js OR base.html: a batch reads forms at the press while the page stays live, and only a test that acts inside the flight can see it → `persistent-memory.d/2026-09-27-one-submit-saves-every-ask.md` - `[2026-09-24]` ⏸ **The upload route's three lifecycle gaps: DEFERRED** — the pickup-id `mkdir` sits outside the try (a FileExistsError race), `rmtree(ignore_errors=True)` hides its own failure, and `except Exception` misses CancelledError. All three are rare; the operator was told and merged without them. Tracked in `225ba32`'s commit message. - `[2026-09-24]` ✅ **Upload names: two crashes found, then two holes in the fix** — READ BEFORE WRITING A SANITISER: drop everything droppable FIRST, then apply the structural rules; a NUL test through httpx `files=` proves nothing → `persistent-memory.d/2026-09-24-upload-names-two-crashes-then-two-holes.md` - `[2026-09-24]` ✅ **The r3 seam pass: what only it could see** — 12 contract-vs-code mismatches folded before code. READ BEFORE A CONTRACT THAT ADDS `data-region`S, MOVES TEMPLATE CODE, OR ADDS A `/b/{name}/` ROUTE → `persistent-memory.d/2026-09-24-r3-seam-pass-what-only-it-could-see.md` diff --git a/tests/mutations/r2_flow.toml b/tests/mutations/r2_flow.toml index d6fc463..d71845b 100644 --- a/tests/mutations/r2_flow.toml +++ b/tests/mutations/r2_flow.toml @@ -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' diff --git a/tests/mutations/r2_submit_all.toml b/tests/mutations/r2_submit_all.toml new file mode 100644 index 0000000..a331e52 --- /dev/null +++ b/tests/mutations/r2_submit_all.toml @@ -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;''' diff --git a/tests/mutations/u3_submit_all.toml b/tests/mutations/u3_submit_all.toml new file mode 100644 index 0000000..878a414 --- /dev/null +++ b/tests/mutations/u3_submit_all.toml @@ -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 = '''''' diff --git a/tests/test_embed_browser.py b/tests/test_embed_browser.py index 2b18fa4..6ec6b09 100644 --- a/tests/test_embed_browser.py +++ b/tests/test_embed_browser.py @@ -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 ; 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"r

R

{SEAM}" + + +@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 = ("r" + '
' + f"{SEAM}") + 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 = ("r" + # 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) + '' + '' + f"{SEAM}") + 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 = ("r" + f"

R

{SEAM}") + 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" diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py index 18f5882..de616de 100644 --- a/tests/test_flow_browser.py +++ b/tests/test_flow_browser.py @@ -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