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
+131
View File
@@ -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 <form> 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;
+3
View File
@@ -62,6 +62,9 @@
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
{% endif %}
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
{# 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. #}
<p class="bk-ask-status" role="status" hidden></p>
</div>
{% endmacro %}
+158 -24
View File
@@ -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]; });
});
})();
</script>
+68 -2
View File
@@ -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
@@ -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 `<form>` 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 <form>
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 `<a id="bk-ask-<id>-top">`
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.
@@ -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 `<form>` = 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 `<form>` 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.
+9 -6
View File
@@ -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}/<word>` ROUTE → `persistent-memory.d/2026-09-24-r3-seam-pass-what-only-it-could-see.md`
+3 -3
View File
@@ -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'
+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;'''
+141
View File
@@ -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 = ''''''
+359
View File
@@ -670,3 +670,362 @@ def test_the_test_browser_has_no_internet(browser, live):
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
# ---- one submit saves every ask on the page (2026-09-27) ---------------------
#
# The operator's report, relayed by infra-ops: "submitting a question should go
# through and submit ALL answers. As it is, I go through, submit a question and
# it only submits the last one and clears out the top ones." Confirmed against
# the live `auk-audition` booth before any code: three single-question asks,
# each its own <form>; the access log shows ONE POST at 15:02:23 saving the
# LAST ask on the page, its 303 reload wiping the other two, then a 400 when a
# now-blank ask was submitted. Contract: u3_declared_embed_seam, "Submitting
# several asks at once".
def _asks(booth, ids=("a1", "a2", "a3")):
"""Single-question asks, declared in `ids` order, which is also their id
order, so `(created, id)` cannot disagree with the order written here."""
from booth.marks import declare_pick
booth.mkdir(parents=True, exist_ok=True)
for mid in ids:
declare_pick(booth, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
return booth
def _answers(booth):
raw = json.loads((booth / ".marks.json").read_text())
return {m["id"]: m.get("answer") for m in raw["marks"]}
def _choice(page, mid, value):
page.check(f'input[name="choice"][form="bk-ask-form-{mid}"][value="{value}"]')
def _press(page, mid):
page.click(f"#bk-ask-{mid}-submit button.bk-ask-go")
def _watch_posts(page):
"""Every POST the page makes, as (resource type, ask id), in send order. A
native form submission is a `document` request; a script's is a `fetch`."""
from urllib.parse import parse_qs
posts = []
def seen(r):
if r.method == "POST":
ask = parse_qs(r.post_data or "").get("ask", [None])[0]
posts.append((r.resource_type, ask))
page.on("request", seen)
return posts
PLAIN = f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>"
@pytest.mark.parametrize("pressed", ["a3", "a1"])
def test_one_submit_saves_every_answered_ask_on_the_page(browser, live, pressed):
"""The operator's exact sequence: answer top to bottom, press the LAST
submit. And the same from the FIRST button, because "press any one" is the
acceptance. Every ask he answered is recorded, and after the page comes
back every pick he made is still showing."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, pressed)
got = _answers(b)
assert {k: (v or {}).get("choice") for k, v in got.items()} == \
{"a1": "yes", "a2": "no", "a3": "yes"}, got
page.wait_for_selector("#bk-ask-a3-submit")
showing = page.evaluate("""() => ['a1', 'a2', 'a3'].map(id => {
var c = document.querySelector('input[name="choice"][form="bk-ask-form-' + id + '"]:checked');
return c ? c.value : null; })""")
page.close()
assert showing == ["yes", "no", "yes"], f"a pick disappeared on reload: {showing}"
def test_a_blank_ask_is_skipped_never_refused(browser, live):
"""Pressing the submit of an ask he left blank used to be a 400 page
("nothing to record"): the live log has one, four seconds after the reload
that wiped his picks. Blanks stay legal: the ask is skipped, never sent, and
the others are saved."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a1", "no")
_choice(page, "a3", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "no" and got["a3"]["choice"] == "yes", got
assert got["a2"] is None, "a blank ask was recorded"
assert [a for _, a in posts] == ["a1", "a3"], posts
def test_an_ask_nobody_touched_is_not_re_sent(browser, live):
"""Re-sending an answer re-dates it, and a reading session sees a fresh
answer that nobody gave. Only what changed since the page loaded is sent —
not the recorded answer sitting under the button that was pressed."""
from booth.marks import answer_pick
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "no")
with page.expect_navigation():
_press(page, "a1") # the "Update answer" button
page.close()
assert [a for _, a in posts] == ["a2"], posts
assert _answers(b)["a2"]["choice"] == "no"
def test_one_changed_ask_still_submits_as_a_plain_form(browser, live):
"""With nothing else on the page to save, a submit is exactly what it was:
the browser's own form POST and its 303. The batch is an addition that
engages only when another ask holds unsent input."""
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a2")
page.close()
assert posts == [("document", "a2")], posts
assert _answers(b)["a2"]["choice"] == "yes"
def test_the_asks_are_sent_in_document_order(browser, live):
"""INV-6: the batch is an ordered collection, and its rule is the order the
forms sit in the document — here the REVERSE of the payload's, because the
author anchored a2 above a1."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
'<div data-booth-mark="a2"></div><div data-booth-mark="a1"></div>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
with page.expect_navigation():
_press(page, "a1")
page.close()
assert [a for _, a in posts] == ["a2", "a1"], posts
def test_a_refused_ask_costs_only_itself_and_clears_nothing(browser, live):
"""The session withdrew a2 while the operator was answering. a1 and a3 are
still saved — one stale ask must not cost the rest — and the page does NOT
reload, so the pick he made for a2 is still on screen, and the page says
which one did not save."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "yes")
_choice(page, "a2", "yes")
_choice(page, "a3", "no")
delete_mark(b, "a2")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a3-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a2"][value="yes"]')
same = page.evaluate("window.__same === 1")
page.close()
got = _answers(b)
assert got["a1"]["choice"] == "yes" and got["a3"]["choice"] == "no", got
assert "a2" not in got
assert same, "a failed batch reloaded the page and cleared what was entered"
assert kept, "the refused ask's pick was cleared"
assert "a2" in said and "2 of 3" in said, said
def test_an_authors_own_form_is_never_taken_over(browser, live):
"""The batch listens to the forms the SCRIPT mounted. An author's own form
on the same report — a search box, say — submits as the author wrote it,
even while the operator has unsent picks, and nothing of ours is sent."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
# an id with OUR prefix, so the mounted-root check is what excludes
# it, not the selector (kimi, hulda: the prefix alone hid the guard)
'<form id="bk-ask-form-theirs" action="/b/b/" method="get"><input name="q" value="x">'
'<button id="go">go</button></form>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
with page.expect_navigation():
page.click("#go")
url = page.url
page.close()
assert posts == [], posts
assert url.endswith("?q=x"), url
assert _answers(b) == {"a1": None, "a2": None}
# ---- the flight window (heid bug-hunt, 2026-09-27, 4 of 4 arms) --------------
#
# The batch reads every form at the press, but the page stays live for the
# whole flight. Every guard that protects that window survived its mutation,
# because no test pressed or edited inside one. These do: each POST's reply is
# held 700ms in the CLIENT, so the window is wide enough to act in on purpose.
_HOLD_POSTS = """
(function () {
var real = window.fetch;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if (o && o.method === 'POST') {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
});
}
return p;
};
})();
"""
def _open_held(browser, base, name, html, booth):
(booth / "index.html").write_text(html, encoding="utf-8")
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
return page
def test_a_press_inside_the_flight_never_fires_a_native_post(browser, live):
"""hulda: press a blank ask's button (a batch of the OTHER one starts), then
that other ask's own button. No other form is dirty any more from its point
of view, so it used to fall through to the browser — a native POST of an
answer already in flight, and a navigation racing the batch."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
posts = _watch_posts(page)
_choice(page, "a2", "yes")
with page.expect_navigation(timeout=10000):
_press(page, "a1")
page.wait_for_timeout(150)
_press(page, "a2")
page.close()
assert posts == [("fetch", "a2")], posts
def test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press(browser, live):
"""All four arms: a successful batch reloaded with no carry, so a pick made
while it was in flight vanished. Now the page stays when anything changed
after the press, says so, and the next press sends ONLY that — the saved
answers are not sent again (their baseline moved to what the server took)."""
base, data = live
b = _asks(data / "b")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
page.evaluate("window.__same = 1")
posts = _watch_posts(page)
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_press(page, "a1")
page.wait_for_timeout(150)
_choice(page, "a3", "no") # mid-flight
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text()
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a3"][value="no"]')
same = page.evaluate("window.__same === 1")
with page.expect_navigation(timeout=10000):
_press(page, "a3")
page.close()
assert same and kept, (same, kept)
assert "not saved yet" in said, said
assert [a for _, a in posts] == ["a1", "a2", "a3"], posts
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no", "a3": "no"}
def test_a_retry_after_a_refusal_sends_only_what_did_not_save(browser, live):
"""groa, hulda: after a partial failure the saved forms still read as dirty,
so the retry re-POSTed them and re-dated answers nobody changed."""
from booth.marks import delete_mark
base, data = live
b = _asks(data / "b")
page = _open(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a3-submit")
for mid in ("a1", "a2", "a3"):
_choice(page, mid, "yes")
delete_mark(b, "a2")
posts = _watch_posts(page)
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.evaluate("document.querySelector('#bk-ask-a3-submit .bk-ask-status').hidden = true")
_press(page, "a3")
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
page.close()
assert [a for _, a in posts] == ["a1", "a2", "a3", "a2"], posts
def test_the_empty_status_line_stays_hidden_under_host_css(browser, live):
"""groa, regin: `p{display:block}` in the author's sheet outranks the UA's
own [hidden]; the embed restates it at class specificity."""
base, data = live
b = _asks(data / "b", ("a1",))
html = ("<!doctype html><title>r</title><style>p{display:block}</style>"
f"<body><h1>R</h1>{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a1-submit")
shown = page.evaluate(
"getComputedStyle(document.querySelector('#bk-ask-a1-submit .bk-ask-status')).display")
page.close()
assert shown == "none", shown
def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live):
"""hulda: the reload waited only on "nothing dirty". A refused form the
operator then set back to its first value reads clean — so the page
reloaded over a failure and never said it. A refusal blocks the reload on
its own."""
from booth.marks import answer_pick, delete_mark
base, data = live
b = _asks(data / "b", ("a1", "a2"))
answer_pick(b, "a1", "yes")
answer_pick(b, "a2", "yes")
page = _open_held(browser, base, "b", PLAIN, b)
page.wait_for_selector("#bk-ask-a2-submit")
page.evaluate("window.__same = 1")
_choice(page, "a1", "no")
_choice(page, "a2", "no")
delete_mark(b, "a1")
_press(page, "a2")
page.wait_for_timeout(150)
_choice(page, "a1", "yes") # back to its first value, mid-flight
page.wait_for_timeout(2500)
same = page.evaluate("window.__same === 1")
shown = page.evaluate(
"!document.querySelector('#bk-ask-a2-submit .bk-ask-status').hidden")
page.close()
assert same, "the page reloaded over a refused POST"
assert shown, "the refusal was never said"
+307
View File
@@ -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