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:
@@ -100,6 +100,10 @@
|
|||||||
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",
|
".bk-ask-was{margin:.15rem 0 .55rem;font-size:.86rem;opacity:.8}",
|
||||||
".bk-ask-was b{opacity:1}",
|
".bk-ask-was b{opacity:1}",
|
||||||
".bk-ask-err{color:var(--bk-err);font-size:.86rem}",
|
".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}}"
|
"@media print{.bk-ask{break-inside:avoid}}"
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
@@ -368,6 +372,133 @@
|
|||||||
if (e.key === "booth.theme" || e.key === null) bkTheme();
|
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() {
|
function start() {
|
||||||
var name = boothName();
|
var name = boothName();
|
||||||
if (!name || !document.body) return;
|
if (!name || !document.body) return;
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
placeholder="{{ a.notes_label }} (optional)">{{ a.answer.notes if a.answer else '' }}</textarea>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="submit" class="bk-ask-go" form="{{ form_id }}">{% if a.answer %}Update answer{% else %}Submit answer{% endif %}</button>
|
<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>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|||||||
+151
-17
@@ -1064,6 +1064,12 @@
|
|||||||
var st = document.querySelector('[data-region="status"]');
|
var st = document.querySelector('[data-region="status"]');
|
||||||
if (st) { st.textContent = text; st.hidden = false; }
|
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
|
/* 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
|
about (which pick, which item, which mark). Stable across renders, where
|
||||||
a position inside a region is not — a form that appears or vanishes
|
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
|
must not (a revealed blur, a closed doc, a disclosure the reader
|
||||||
opened or closed); and every DIRTY control — a half-typed note, an
|
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,
|
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
|
never by position. The forms just sent are the exception: their fields
|
||||||
its disclosure come back as the server rendered them. */
|
and their disclosures come back as the server rendered them — unless the
|
||||||
function carry(oldEl, newEl, sent) {
|
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]'));
|
var olds = [].slice.call(oldEl.querySelectorAll('img[src], video[src], audio[src]'));
|
||||||
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
newEl.querySelectorAll('img[src], video[src], audio[src]').forEach(function (m) {
|
||||||
for (var i = 0; i < olds.length; i++) {
|
for (var i = 0; i < olds.length; i++) {
|
||||||
@@ -1123,7 +1132,7 @@
|
|||||||
var oldDetails = detailsMap(oldEl);
|
var oldDetails = detailsMap(oldEl);
|
||||||
Object.keys(oldDetails).forEach(function (k) {
|
Object.keys(oldDetails).forEach(function (k) {
|
||||||
var d = oldDetails[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;
|
if (freshDetails[k]) freshDetails[k].open = d.open;
|
||||||
});
|
});
|
||||||
var freshFields = {};
|
var freshFields = {};
|
||||||
@@ -1131,7 +1140,7 @@
|
|||||||
if (el.type !== 'hidden') freshFields[fieldKey(el)] = el;
|
if (el.type !== 'hidden') freshFields[fieldKey(el)] = el;
|
||||||
});
|
});
|
||||||
oldEl.querySelectorAll('textarea, input').forEach(function (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)];
|
var t = freshFields[fieldKey(el)];
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
if (el.type === 'radio' || el.type === 'checkbox') {
|
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
|
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
|
vanished — or when the page has no region to swap at all. Then only a
|
||||||
reload tells the truth. */
|
reload tells the truth. */
|
||||||
function swap(html, sent) {
|
function swap(html, isSent) {
|
||||||
var fresh = new DOMParser().parseFromString(html, 'text/html');
|
var fresh = new DOMParser().parseFromString(html, 'text/html');
|
||||||
var freshById = {}, liveIds = {};
|
var freshById = {}, liveIds = {};
|
||||||
fresh.querySelectorAll('[data-region]').forEach(function (c) {
|
fresh.querySelectorAll('[data-region]').forEach(function (c) {
|
||||||
@@ -1164,7 +1173,7 @@
|
|||||||
var next = freshById[id];
|
var next = freshById[id];
|
||||||
if (next) {
|
if (next) {
|
||||||
var node = document.importNode(next, true);
|
var node = document.importNode(next, true);
|
||||||
carry(el, node, sent);
|
carry(el, node, isSent);
|
||||||
el.replaceWith(node);
|
el.replaceWith(node);
|
||||||
swapped++;
|
swapped++;
|
||||||
} else if (id.indexOf('item-') === 0) {
|
} else if (id.indexOf('item-') === 0) {
|
||||||
@@ -1184,37 +1193,162 @@
|
|||||||
/* SERIALIZED: each save runs its POST, its re-fetch and its swap before
|
/* 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.
|
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
|
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();
|
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, {
|
return fetch(form.action, {
|
||||||
method: 'POST', body: new URLSearchParams(data),
|
method: 'POST', body: new URLSearchParams(data),
|
||||||
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
headers: {'Accept': 'application/json'}, credentials: 'same-origin'
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
if (r.status !== 204) throw new Error('status ' + r.status);
|
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) {
|
}
|
||||||
|
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);
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
return r.text();
|
return r.text();
|
||||||
}).then(function (html) {
|
}).then(function (html) {
|
||||||
if (!swap(html, form)) window.location.reload();
|
if (swap(html, sentSet(recs))) return true;
|
||||||
}).catch(function () {
|
if (!keep) window.location.reload();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function fail() {
|
||||||
/* Said, then reloaded after a beat, so the words are readable rather
|
/* Said, then reloaded after a beat, so the words are readable rather
|
||||||
than a flash before the page goes. */
|
than a flash before the page goes. */
|
||||||
say('Could not save in place — reloading to show what was saved.');
|
say('Could not save in place — reloading to show what was saved.');
|
||||||
setTimeout(function () { window.location.reload(); }, 900);
|
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) {
|
document.addEventListener('submit', function (ev) {
|
||||||
var form = ev.target;
|
var form = ev.target;
|
||||||
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
if (!form.matches || !form.matches('form[data-inplace]') || ev.defaultPrevented) return;
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (form.__busy) return;
|
if (pending[flightKey(form)]) return;
|
||||||
form.__busy = true;
|
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);
|
var data = new FormData(form);
|
||||||
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
if (ev.submitter && ev.submitter.name) data.append(ev.submitter.name, ev.submitter.value);
|
||||||
queue = queue.then(function () { return run(form, data); })
|
queue = queue.then(function () { return run(form, data, snap); })
|
||||||
.then(function () { form.__busy = false; });
|
.then(function () { delete pending[key]; });
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
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
|
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
|
(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
|
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.
|
the judgment: a duplicate note, or a re-dated answer.
|
||||||
- On a non-204 HTTP response, or a network failure, it writes a fixed
|
- 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,
|
guards against is a form the operator fills in whose controls reach no form,
|
||||||
so the button does nothing.
|
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">`
|
**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
|
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;
|
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
|
and `test_partially_marked_page_still_shows_every_question` fails in the browser
|
||||||
with 2 of 4 radio groups present.
|
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)
|
## Out of scope (deferred or never)
|
||||||
|
|
||||||
Named so a reviewer does not read them as drift.
|
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Persistent memory — booth
|
# 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
|
> **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
|
> `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
|
## 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
|
- ✅ **ONE SUBMIT SAVES EVERY ASK ON A PAGE** (2026-09-27, Prime's bug via
|
||||||
clean, no branch or worktree is open, and no peer is waiting on booth-dev.
|
infra-ops, thread `01M3JED397G1SZH7580PCXNVVA`). Client-side on both
|
||||||
main is ONE memory commit ahead of origin (this snapshot), unpushed: the
|
surfaces (embed.js, base.html's in-place script), no server change; a
|
||||||
push is the operator's call.
|
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
|
- ✅ **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
|
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
|
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
|
## 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]` ⏸ **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]` ✅ **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`
|
- `[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`
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ new = ''' except OSError:
|
|||||||
label = 'C3 client: no busy guard (a double-click writes twice)'
|
label = 'C3 client: no busy guard (a double-click writes twice)'
|
||||||
file = "booth/templates/base.html"
|
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"
|
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 = ''''''
|
new = ''''''
|
||||||
|
|
||||||
@@ -133,8 +133,8 @@ new = ''''''
|
|||||||
label = 'C3 client: saves are not serialized'
|
label = 'C3 client: saves are not serialized'
|
||||||
file = "booth/templates/base.html"
|
file = "booth/templates/base.html"
|
||||||
test = "tests/test_flow_browser.py::test_quick_successive_flags_all_show"
|
test = "tests/test_flow_browser.py::test_quick_successive_flags_all_show"
|
||||||
old = ''' queue = queue.then(function () { return run(form, data); })'''
|
old = ''' queue = queue.then(function () { return run(form, data, snap); })'''
|
||||||
new = ''' queue = run(form, data)'''
|
new = ''' queue = run(form, data, snap)'''
|
||||||
|
|
||||||
[[mutation]]
|
[[mutation]]
|
||||||
label = 'C3 the standalone marks page has no region'
|
label = 'C3 the standalone marks page has no region'
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# R2 C3 step 3a, 2026-09-27: one submit saves every changed pick on a Booth
|
||||||
|
# page (the marks page, the lightbox's verdict aside, the review rail). The
|
||||||
|
# Booth-page half of the operator's report; the verbatim half is
|
||||||
|
# u3_submit_all.toml. Contract: docs/contracts/r2_flow.contract.md, C3 step 3a.
|
||||||
|
# The flight-window and refusal rows came from the heid bug-hunt panel on the
|
||||||
|
# first cut.
|
||||||
|
|
||||||
|
unit = "r2 submit all"
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the other pick forms are ignored (one form, one save, as before)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_one_submit_on_the_marks_page_saves_every_changed_pick"
|
||||||
|
old = '''
|
||||||
|
var batch = pickBatch(form);'''
|
||||||
|
new = '''
|
||||||
|
var batch = null;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the pressed pick is sent even when blank (its 400 reloads the rest away)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it"
|
||||||
|
old = '''
|
||||||
|
return others ? picks.filter(function (f) { return dirty(f) && !pending[flightKey(f)]; }) : null;'''
|
||||||
|
new = '''
|
||||||
|
return others ? picks.filter(function (f) { return (f === form || dirty(f)) && !pending[flightKey(f)]; }) : null;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refusal stops the picks after it"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
return post(f, datas[i]).then(function () {'''
|
||||||
|
new = '''
|
||||||
|
if (refused.length) return;
|
||||||
|
return post(f, datas[i]).then(function () {'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refused batch reloads (the refused pick and every draft are lost)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
|
||||||
|
new = '''
|
||||||
|
if (refused.length) { fail(); return; }
|
||||||
|
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refused pick counts as sent, so its input comes back as the server has it"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
}, function (e) { refused.push(askOf(f) + ' (' + e.message + ')'); });'''
|
||||||
|
new = '''
|
||||||
|
}, function (e) { saved.push({key: flightKey(f), snap: snaps[i]}); refused.push(askOf(f) + ' (' + e.message + ')'); });'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a form in flight stops counting as another dirty form (a clean press 400s mid-save)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_pressing_a_clean_pick_during_a_batch_sends_nothing"
|
||||||
|
old = '''
|
||||||
|
return f.getAttribute('action') === action && isPick(f);'''
|
||||||
|
new = '''
|
||||||
|
return f.getAttribute('action') === action && isPick(f) && !pending[flightKey(f)];'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "in flight is marked on the node, so a swap's fresh copy can be sent twice"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_pick_in_flight_stays_in_flight_across_another_saves_swap"
|
||||||
|
old = '''
|
||||||
|
function flightKey(f) { return formKey(f); }'''
|
||||||
|
new = '''
|
||||||
|
var nth = 0;
|
||||||
|
function flightKey(f) { return f.__fk || (f.__fk = 'n' + (++nth)); }'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a new save does not clear the last one's words"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_later_save_clears_a_stale_not_saved_line"
|
||||||
|
old = '''
|
||||||
|
if (st) { st.textContent = ''; st.hidden = true; }'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a batch whose refresh fails reloads (every unsent draft with it)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_batch_whose_refresh_fails_keeps_the_page"
|
||||||
|
old = '''
|
||||||
|
var shown = saved.length ? refresh(saved, true) : Promise.resolve(true);'''
|
||||||
|
new = '''
|
||||||
|
var shown = saved.length ? refresh(saved, true).catch(function (e) { fail(); throw e; }) : Promise.resolve(true);'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a sent form counts as sent even when it changed after the press"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_change_to_a_sent_pick_during_the_flight_is_kept"
|
||||||
|
old = '''
|
||||||
|
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
|
||||||
|
new = '''
|
||||||
|
if (recs[i].key === k) return true;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "no form counts as sent (a saved note's text carries back as a draft)"
|
||||||
|
file = "booth/templates/base.html"
|
||||||
|
test = "tests/test_flow_browser.py::test_a_saved_notes_box_comes_back_empty"
|
||||||
|
old = '''
|
||||||
|
if (recs[i].key === k && recs[i].snap === serial(f)) return true;'''
|
||||||
|
new = '''
|
||||||
|
if (false) return true;'''
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# U3 amendment, 2026-09-27: one submit saves every ask on a verbatim report.
|
||||||
|
# The operator answered three asks top to bottom, pressed the last button, and
|
||||||
|
# the 303 reload wiped the first two (`auk-audition`, 15:02:23). Contract:
|
||||||
|
# docs/contracts/u3_declared_embed_seam.contract.md, "Submitting several asks at
|
||||||
|
# once" and INV-8. Every row is a change tests/test_embed_browser.py claims to
|
||||||
|
# forbid. The flight-window rows came from the heid bug-hunt panel on the first
|
||||||
|
# cut, where every guard of that window survived its mutation.
|
||||||
|
|
||||||
|
unit = "u3 submit all"
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "only the pressed form is sent (the reported defect)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_one_submit_saves_every_answered_ask_on_the_page[a3]"
|
||||||
|
old = '''
|
||||||
|
var batch = forms.filter(dirty);'''
|
||||||
|
new = '''
|
||||||
|
var batch = [form];'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the pressed form is sent whether or not it is dirty (a blank one 400s)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_blank_ask_is_skipped_never_refused"
|
||||||
|
old = '''
|
||||||
|
var batch = forms.filter(dirty);'''
|
||||||
|
new = '''
|
||||||
|
var batch = forms.filter(function (f) { return f === form || dirty(f); });'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "every form is sent, touched or not (re-dates an answer nobody gave)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_an_ask_nobody_touched_is_not_re_sent"
|
||||||
|
old = '''
|
||||||
|
var batch = forms.filter(dirty);'''
|
||||||
|
new = '''
|
||||||
|
var batch = forms;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a lone dirty form is intercepted instead of left to the browser"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_one_changed_ask_still_submits_as_a_plain_form"
|
||||||
|
old = '''
|
||||||
|
if (!others) return; // the browser's own POST and 303'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the forms are sent in reverse document order"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_the_asks_are_sent_in_document_order"
|
||||||
|
old = '''
|
||||||
|
var batch = forms.filter(dirty);'''
|
||||||
|
new = '''
|
||||||
|
var batch = forms.filter(dirty).reverse();'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refusal stops the forms after it"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
return send(f, bodies[n]).then(function (why) {'''
|
||||||
|
new = '''
|
||||||
|
if (failed.length) return;
|
||||||
|
return send(f, bodies[n]).then(function (why) {'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refusal reloads the page and clears what was entered"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
if (!failed.length && !changed) { location.reload(); return; }'''
|
||||||
|
new = '''
|
||||||
|
location.reload(); return;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "only dirtiness blocks the reload (a refused form set back to its first value reloads over the failure)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty"
|
||||||
|
old = '''
|
||||||
|
if (!failed.length && !changed) { location.reload(); return; }'''
|
||||||
|
new = '''
|
||||||
|
if (!changed) { location.reload(); return; }'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a refusal is never said"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_refused_ask_costs_only_itself_and_clears_nothing"
|
||||||
|
old = '''
|
||||||
|
st.hidden = false;'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "an author's own form is taken over (no ownership check at the entry)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
|
||||||
|
old = '''
|
||||||
|
if (forms.indexOf(form) < 0) return;'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "an author's form wearing our id prefix counts as ours (no mounted-root check)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_an_authors_own_form_is_never_taken_over"
|
||||||
|
old = '''
|
||||||
|
if (ours[j].contains(all[i])) { out.push(all[i]); break; }'''
|
||||||
|
new = '''
|
||||||
|
out.push(all[i]); break;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a press during the flight falls through to the browser (a native POST races the batch)"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_press_inside_the_flight_never_fires_a_native_post"
|
||||||
|
old = '''
|
||||||
|
if (sending) { ev.preventDefault(); return; }'''
|
||||||
|
new = ''''''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a change made during the flight is reloaded away"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press"
|
||||||
|
old = '''
|
||||||
|
var changed = forms.some(dirty);'''
|
||||||
|
new = '''
|
||||||
|
var changed = false;'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "a saved form keeps its old baseline, so a retry re-sends (re-dates) it"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_a_retry_after_a_refusal_sends_only_what_did_not_save"
|
||||||
|
old = '''
|
||||||
|
if (why === null) f.__bkSaved = bodies[n].toString();
|
||||||
|
else failed.push'''
|
||||||
|
new = '''
|
||||||
|
if (why !== null) failed.push'''
|
||||||
|
|
||||||
|
[[mutation]]
|
||||||
|
label = "the empty status line shows under a host `p{display:block}`"
|
||||||
|
file = "booth/static/embed.js"
|
||||||
|
test = "tests/test_embed_browser.py::test_the_empty_status_line_stays_hidden_under_host_css"
|
||||||
|
old = '''
|
||||||
|
".bk-ask-status[hidden]{display:none}",'''
|
||||||
|
new = ''''''
|
||||||
@@ -670,3 +670,362 @@ def test_the_test_browser_has_no_internet(browser, live):
|
|||||||
page.close()
|
page.close()
|
||||||
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
|
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
|
||||||
assert local < 10, local
|
assert local < 10, local
|
||||||
|
|
||||||
|
|
||||||
|
# ---- one submit saves every ask on the page (2026-09-27) ---------------------
|
||||||
|
#
|
||||||
|
# The operator's report, relayed by infra-ops: "submitting a question should go
|
||||||
|
# through and submit ALL answers. As it is, I go through, submit a question and
|
||||||
|
# it only submits the last one and clears out the top ones." Confirmed against
|
||||||
|
# the live `auk-audition` booth before any code: three single-question asks,
|
||||||
|
# each its own <form>; the access log shows ONE POST at 15:02:23 saving the
|
||||||
|
# LAST ask on the page, its 303 reload wiping the other two, then a 400 when a
|
||||||
|
# now-blank ask was submitted. Contract: u3_declared_embed_seam, "Submitting
|
||||||
|
# several asks at once".
|
||||||
|
|
||||||
|
|
||||||
|
def _asks(booth, ids=("a1", "a2", "a3")):
|
||||||
|
"""Single-question asks, declared in `ids` order, which is also their id
|
||||||
|
order, so `(created, id)` cannot disagree with the order written here."""
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
|
||||||
|
booth.mkdir(parents=True, exist_ok=True)
|
||||||
|
for mid in ids:
|
||||||
|
declare_pick(booth, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
|
||||||
|
return booth
|
||||||
|
|
||||||
|
|
||||||
|
def _answers(booth):
|
||||||
|
raw = json.loads((booth / ".marks.json").read_text())
|
||||||
|
return {m["id"]: m.get("answer") for m in raw["marks"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _choice(page, mid, value):
|
||||||
|
page.check(f'input[name="choice"][form="bk-ask-form-{mid}"][value="{value}"]')
|
||||||
|
|
||||||
|
|
||||||
|
def _press(page, mid):
|
||||||
|
page.click(f"#bk-ask-{mid}-submit button.bk-ask-go")
|
||||||
|
|
||||||
|
|
||||||
|
def _watch_posts(page):
|
||||||
|
"""Every POST the page makes, as (resource type, ask id), in send order. A
|
||||||
|
native form submission is a `document` request; a script's is a `fetch`."""
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
|
||||||
|
def seen(r):
|
||||||
|
if r.method == "POST":
|
||||||
|
ask = parse_qs(r.post_data or "").get("ask", [None])[0]
|
||||||
|
posts.append((r.resource_type, ask))
|
||||||
|
page.on("request", seen)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
PLAIN = f"<!doctype html><title>r</title><body><h1>R</h1>{SEAM}</body>"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pressed", ["a3", "a1"])
|
||||||
|
def test_one_submit_saves_every_answered_ask_on_the_page(browser, live, pressed):
|
||||||
|
"""The operator's exact sequence: answer top to bottom, press the LAST
|
||||||
|
submit. And the same from the FIRST button, because "press any one" is the
|
||||||
|
acceptance. Every ask he answered is recorded, and after the page comes
|
||||||
|
back every pick he made is still showing."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
_choice(page, "a1", "yes")
|
||||||
|
_choice(page, "a2", "no")
|
||||||
|
_choice(page, "a3", "yes")
|
||||||
|
with page.expect_navigation():
|
||||||
|
_press(page, pressed)
|
||||||
|
got = _answers(b)
|
||||||
|
assert {k: (v or {}).get("choice") for k, v in got.items()} == \
|
||||||
|
{"a1": "yes", "a2": "no", "a3": "yes"}, got
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
showing = page.evaluate("""() => ['a1', 'a2', 'a3'].map(id => {
|
||||||
|
var c = document.querySelector('input[name="choice"][form="bk-ask-form-' + id + '"]:checked');
|
||||||
|
return c ? c.value : null; })""")
|
||||||
|
page.close()
|
||||||
|
assert showing == ["yes", "no", "yes"], f"a pick disappeared on reload: {showing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_blank_ask_is_skipped_never_refused(browser, live):
|
||||||
|
"""Pressing the submit of an ask he left blank used to be a 400 page
|
||||||
|
("nothing to record"): the live log has one, four seconds after the reload
|
||||||
|
that wiped his picks. Blanks stay legal: the ask is skipped, never sent, and
|
||||||
|
the others are saved."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a1", "no")
|
||||||
|
_choice(page, "a3", "yes")
|
||||||
|
with page.expect_navigation():
|
||||||
|
_press(page, "a2")
|
||||||
|
page.close()
|
||||||
|
got = _answers(b)
|
||||||
|
assert got["a1"]["choice"] == "no" and got["a3"]["choice"] == "yes", got
|
||||||
|
assert got["a2"] is None, "a blank ask was recorded"
|
||||||
|
assert [a for _, a in posts] == ["a1", "a3"], posts
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_ask_nobody_touched_is_not_re_sent(browser, live):
|
||||||
|
"""Re-sending an answer re-dates it, and a reading session sees a fresh
|
||||||
|
answer that nobody gave. Only what changed since the page loaded is sent —
|
||||||
|
not the recorded answer sitting under the button that was pressed."""
|
||||||
|
from booth.marks import answer_pick
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1", "a2"))
|
||||||
|
answer_pick(b, "a1", "yes")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a2-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a2", "no")
|
||||||
|
with page.expect_navigation():
|
||||||
|
_press(page, "a1") # the "Update answer" button
|
||||||
|
page.close()
|
||||||
|
assert [a for _, a in posts] == ["a2"], posts
|
||||||
|
assert _answers(b)["a2"]["choice"] == "no"
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_changed_ask_still_submits_as_a_plain_form(browser, live):
|
||||||
|
"""With nothing else on the page to save, a submit is exactly what it was:
|
||||||
|
the browser's own form POST and its 303. The batch is an addition that
|
||||||
|
engages only when another ask holds unsent input."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a2", "yes")
|
||||||
|
with page.expect_navigation():
|
||||||
|
_press(page, "a2")
|
||||||
|
page.close()
|
||||||
|
assert posts == [("document", "a2")], posts
|
||||||
|
assert _answers(b)["a2"]["choice"] == "yes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_asks_are_sent_in_document_order(browser, live):
|
||||||
|
"""INV-6: the batch is an ordered collection, and its rule is the order the
|
||||||
|
forms sit in the document — here the REVERSE of the payload's, because the
|
||||||
|
author anchored a2 above a1."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1", "a2"))
|
||||||
|
html = ("<!doctype html><title>r</title><body>"
|
||||||
|
'<div data-booth-mark="a2"></div><div data-booth-mark="a1"></div>'
|
||||||
|
f"{SEAM}</body>")
|
||||||
|
page = _open(browser, base, "b", html, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a1-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a1", "yes")
|
||||||
|
_choice(page, "a2", "yes")
|
||||||
|
with page.expect_navigation():
|
||||||
|
_press(page, "a1")
|
||||||
|
page.close()
|
||||||
|
assert [a for _, a in posts] == ["a2", "a1"], posts
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_ask_costs_only_itself_and_clears_nothing(browser, live):
|
||||||
|
"""The session withdrew a2 while the operator was answering. a1 and a3 are
|
||||||
|
still saved — one stale ask must not cost the rest — and the page does NOT
|
||||||
|
reload, so the pick he made for a2 is still on screen, and the page says
|
||||||
|
which one did not save."""
|
||||||
|
from booth.marks import delete_mark
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
_choice(page, "a1", "yes")
|
||||||
|
_choice(page, "a2", "yes")
|
||||||
|
_choice(page, "a3", "no")
|
||||||
|
delete_mark(b, "a2")
|
||||||
|
_press(page, "a3")
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||||
|
said = page.locator("#bk-ask-a3-submit .bk-ask-status").inner_text()
|
||||||
|
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a2"][value="yes"]')
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
page.close()
|
||||||
|
got = _answers(b)
|
||||||
|
assert got["a1"]["choice"] == "yes" and got["a3"]["choice"] == "no", got
|
||||||
|
assert "a2" not in got
|
||||||
|
assert same, "a failed batch reloaded the page and cleared what was entered"
|
||||||
|
assert kept, "the refused ask's pick was cleared"
|
||||||
|
assert "a2" in said and "2 of 3" in said, said
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_authors_own_form_is_never_taken_over(browser, live):
|
||||||
|
"""The batch listens to the forms the SCRIPT mounted. An author's own form
|
||||||
|
on the same report — a search box, say — submits as the author wrote it,
|
||||||
|
even while the operator has unsent picks, and nothing of ours is sent."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1", "a2"))
|
||||||
|
html = ("<!doctype html><title>r</title><body>"
|
||||||
|
# an id with OUR prefix, so the mounted-root check is what excludes
|
||||||
|
# it, not the selector (kimi, hulda: the prefix alone hid the guard)
|
||||||
|
'<form id="bk-ask-form-theirs" action="/b/b/" method="get"><input name="q" value="x">'
|
||||||
|
'<button id="go">go</button></form>'
|
||||||
|
f"{SEAM}</body>")
|
||||||
|
page = _open(browser, base, "b", html, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a2-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a1", "yes")
|
||||||
|
_choice(page, "a2", "no")
|
||||||
|
with page.expect_navigation():
|
||||||
|
page.click("#go")
|
||||||
|
url = page.url
|
||||||
|
page.close()
|
||||||
|
assert posts == [], posts
|
||||||
|
assert url.endswith("?q=x"), url
|
||||||
|
assert _answers(b) == {"a1": None, "a2": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- the flight window (heid bug-hunt, 2026-09-27, 4 of 4 arms) --------------
|
||||||
|
#
|
||||||
|
# The batch reads every form at the press, but the page stays live for the
|
||||||
|
# whole flight. Every guard that protects that window survived its mutation,
|
||||||
|
# because no test pressed or edited inside one. These do: each POST's reply is
|
||||||
|
# held 700ms in the CLIENT, so the window is wide enough to act in on purpose.
|
||||||
|
|
||||||
|
_HOLD_POSTS = """
|
||||||
|
(function () {
|
||||||
|
var real = window.fetch;
|
||||||
|
window.fetch = function (u, o) {
|
||||||
|
var p = real.apply(this, arguments);
|
||||||
|
if (o && o.method === 'POST') {
|
||||||
|
return p.then(function (r) {
|
||||||
|
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _open_held(browser, base, name, html, booth):
|
||||||
|
(booth / "index.html").write_text(html, encoding="utf-8")
|
||||||
|
page = browser.new_page()
|
||||||
|
page.add_init_script(_HOLD_POSTS)
|
||||||
|
page.goto(f"{base}/b/{name}/", wait_until="networkidle")
|
||||||
|
return page
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_press_inside_the_flight_never_fires_a_native_post(browser, live):
|
||||||
|
"""hulda: press a blank ask's button (a batch of the OTHER one starts), then
|
||||||
|
that other ask's own button. No other form is dirty any more from its point
|
||||||
|
of view, so it used to fall through to the browser — a native POST of an
|
||||||
|
answer already in flight, and a navigation racing the batch."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1", "a2"))
|
||||||
|
page = _open_held(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a2-submit")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a2", "yes")
|
||||||
|
with page.expect_navigation(timeout=10000):
|
||||||
|
_press(page, "a1")
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
_press(page, "a2")
|
||||||
|
page.close()
|
||||||
|
assert posts == [("fetch", "a2")], posts
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_made_during_the_flight_is_kept_and_saved_on_the_next_press(browser, live):
|
||||||
|
"""All four arms: a successful batch reloaded with no carry, so a pick made
|
||||||
|
while it was in flight vanished. Now the page stays when anything changed
|
||||||
|
after the press, says so, and the next press sends ONLY that — the saved
|
||||||
|
answers are not sent again (their baseline moved to what the server took)."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open_held(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_choice(page, "a1", "yes")
|
||||||
|
_choice(page, "a2", "no")
|
||||||
|
_press(page, "a1")
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
_choice(page, "a3", "no") # mid-flight
|
||||||
|
page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||||
|
said = page.locator("#bk-ask-a1-submit .bk-ask-status").inner_text()
|
||||||
|
kept = page.is_checked('input[name="choice"][form="bk-ask-form-a3"][value="no"]')
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
with page.expect_navigation(timeout=10000):
|
||||||
|
_press(page, "a3")
|
||||||
|
page.close()
|
||||||
|
assert same and kept, (same, kept)
|
||||||
|
assert "not saved yet" in said, said
|
||||||
|
assert [a for _, a in posts] == ["a1", "a2", "a3"], posts
|
||||||
|
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no", "a3": "no"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_retry_after_a_refusal_sends_only_what_did_not_save(browser, live):
|
||||||
|
"""groa, hulda: after a partial failure the saved forms still read as dirty,
|
||||||
|
so the retry re-POSTed them and re-dated answers nobody changed."""
|
||||||
|
from booth.marks import delete_mark
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b")
|
||||||
|
page = _open(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit")
|
||||||
|
for mid in ("a1", "a2", "a3"):
|
||||||
|
_choice(page, mid, "yes")
|
||||||
|
delete_mark(b, "a2")
|
||||||
|
posts = _watch_posts(page)
|
||||||
|
_press(page, "a3")
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||||
|
page.evaluate("document.querySelector('#bk-ask-a3-submit .bk-ask-status').hidden = true")
|
||||||
|
_press(page, "a3")
|
||||||
|
page.wait_for_selector("#bk-ask-a3-submit .bk-ask-status:not([hidden])", timeout=10000)
|
||||||
|
page.close()
|
||||||
|
assert [a for _, a in posts] == ["a1", "a2", "a3", "a2"], posts
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_empty_status_line_stays_hidden_under_host_css(browser, live):
|
||||||
|
"""groa, regin: `p{display:block}` in the author's sheet outranks the UA's
|
||||||
|
own [hidden]; the embed restates it at class specificity."""
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1",))
|
||||||
|
html = ("<!doctype html><title>r</title><style>p{display:block}</style>"
|
||||||
|
f"<body><h1>R</h1>{SEAM}</body>")
|
||||||
|
page = _open(browser, base, "b", html, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a1-submit")
|
||||||
|
shown = page.evaluate(
|
||||||
|
"getComputedStyle(document.querySelector('#bk-ask-a1-submit .bk-ask-status')).display")
|
||||||
|
page.close()
|
||||||
|
assert shown == "none", shown
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live):
|
||||||
|
"""hulda: the reload waited only on "nothing dirty". A refused form the
|
||||||
|
operator then set back to its first value reads clean — so the page
|
||||||
|
reloaded over a failure and never said it. A refusal blocks the reload on
|
||||||
|
its own."""
|
||||||
|
from booth.marks import answer_pick, delete_mark
|
||||||
|
|
||||||
|
base, data = live
|
||||||
|
b = _asks(data / "b", ("a1", "a2"))
|
||||||
|
answer_pick(b, "a1", "yes")
|
||||||
|
answer_pick(b, "a2", "yes")
|
||||||
|
page = _open_held(browser, base, "b", PLAIN, b)
|
||||||
|
page.wait_for_selector("#bk-ask-a2-submit")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
_choice(page, "a1", "no")
|
||||||
|
_choice(page, "a2", "no")
|
||||||
|
delete_mark(b, "a1")
|
||||||
|
_press(page, "a2")
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
_choice(page, "a1", "yes") # back to its first value, mid-flight
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
shown = page.evaluate(
|
||||||
|
"!document.querySelector('#bk-ask-a2-submit .bk-ask-status').hidden")
|
||||||
|
page.close()
|
||||||
|
assert same, "the page reloaded over a refused POST"
|
||||||
|
assert shown, "the refusal was never said"
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
|
|||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
|
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.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
(b / ".marks.json").write_text("{damaged")
|
(b / ".marks.json").write_text("{damaged")
|
||||||
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||||
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
|
# 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()
|
said = page.locator('[data-region="status"]').inner_text()
|
||||||
page.wait_for_load_state("networkidle")
|
page.wait_for_load_state("networkidle")
|
||||||
page.wait_for_timeout(1500) # past the reload
|
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()
|
page.close()
|
||||||
assert "Could not save in place" in said
|
assert "Could not save in place" in said
|
||||||
|
assert reloaded, "the failure path never reloaded"
|
||||||
assert len(posts) == 1, f"re-POSTed: {posts}"
|
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["gutter"] >= 15, ("the forced classic scrollbar is not in effect", g)
|
||||||
assert g["next_right"] <= g["client_right"] - 8 + 1, g
|
assert g["next_right"] <= g["client_right"] - 8 + 1, g
|
||||||
assert left == 800, left
|
assert left == 800, left
|
||||||
|
|
||||||
|
|
||||||
|
# ---- one submit saves every changed pick on the page (2026-09-27) -------------
|
||||||
|
#
|
||||||
|
# The Booth-page half of the operator's report (the verbatim half is in
|
||||||
|
# test_embed_browser.py). Here an unsent pick SURVIVED another save — C3 carries
|
||||||
|
# dirty controls — but it was never SAVED, and pressing the submit of a blank
|
||||||
|
# pick was a 400, whose failure path reloads and wipes every one of them.
|
||||||
|
# Contract: r2_flow C3, "Several picks at once".
|
||||||
|
|
||||||
|
|
||||||
|
def _picks(b, ids=("a1", "a2", "a3")):
|
||||||
|
from booth.marks import declare_pick
|
||||||
|
for mid in ids:
|
||||||
|
declare_pick(b, mid, {"prompt": f"About {mid}?", "options": ["yes", "no"]})
|
||||||
|
|
||||||
|
|
||||||
|
def _chosen(b):
|
||||||
|
from booth.marks import marks_for
|
||||||
|
return {m.id: (m.answer or {}).get("choice") for m in marks_for(b) if m.shape == "pick"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_submit_on_the_marks_page_saves_every_changed_pick(browser, live):
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('#mark-a2 input[type=radio][value="no"]')
|
||||||
|
page.check('#mark-a3 input[type=radio][value="yes"]')
|
||||||
|
page.locator("#mark-a3 .mark-submit").click()
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 3", timeout=10000)
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
page.close()
|
||||||
|
assert _chosen(b) == {"a1": "yes", "a2": "no", "a3": "yes"}
|
||||||
|
assert same, "the save reloaded instead of landing in place"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pressing_a_blank_picks_submit_saves_the_others_and_skips_it(browser, live):
|
||||||
|
"""On the lightbox, from the verdict aside. The pressed pick is blank: it
|
||||||
|
is not sent (a 400, whose failure reload wiped the rest), the other two
|
||||||
|
are, and the page stays put."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 3)
|
||||||
|
_picks(b)
|
||||||
|
posts = []
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
page.check('.verdict #mark-a1 input[type=radio][value="no"]')
|
||||||
|
page.check('.verdict #mark-a3 input[type=radio][value="no"]')
|
||||||
|
page.locator(".verdict #mark-a2 .mark-submit").click()
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.verdict .mark-pick.is-answered').length === 2", timeout=10000)
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
page.close()
|
||||||
|
assert _chosen(b) == {"a1": "no", "a2": None, "a3": "no"}
|
||||||
|
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
|
||||||
|
assert same
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_pick_in_a_batch_costs_only_itself_and_clears_nothing(browser, live):
|
||||||
|
"""a2's POST never reaches the server (the network drops it). a1 and a3
|
||||||
|
still land, nothing is sent twice, and — heid bug-hunt, 3 of 4 arms — the
|
||||||
|
page does NOT reload: the saved picks come back in place, a2's pick and a
|
||||||
|
half-typed note are still on screen, and the status line names a2."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b)
|
||||||
|
posts = []
|
||||||
|
page = browser.new_page()
|
||||||
|
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
|
||||||
|
page.route("**/b/g/answer", lambda route: route.abort()
|
||||||
|
if "ask=a2" in (route.request.post_data or "") else route.continue_())
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
for mid in ("a1", "a2", "a3"):
|
||||||
|
page.check(f'#mark-{mid} input[type=radio][value="yes"]')
|
||||||
|
page.locator(".mark-add textarea").fill("half a thought")
|
||||||
|
page.locator("#mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||||
|
said = page.locator('[data-region="status"]').inner_text()
|
||||||
|
page.wait_for_timeout(1500) # past where a reload would have been
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
kept = page.is_checked('#mark-a2 input[type=radio][value="yes"]')
|
||||||
|
draft = page.locator(".mark-add textarea").input_value()
|
||||||
|
page.close()
|
||||||
|
assert same, "a refused batch reloaded the page"
|
||||||
|
assert kept and draft == "half a thought", (kept, draft)
|
||||||
|
assert "a2" in said and "2 of 3" in said, said
|
||||||
|
assert _chosen(b) == {"a1": "yes", "a2": None, "a3": "yes"}
|
||||||
|
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a2", "ask=a3"], posts
|
||||||
|
|
||||||
|
|
||||||
|
# Every POST's reply held 700ms in the CLIENT, so a second press can land
|
||||||
|
# inside the flight on purpose rather than by luck.
|
||||||
|
_HOLD_POSTS = """
|
||||||
|
(function () {
|
||||||
|
var real = window.fetch;
|
||||||
|
window.fetch = function (u, o) {
|
||||||
|
var p = real.apply(this, arguments);
|
||||||
|
if (o && o.method === 'POST') {
|
||||||
|
return p.then(function (r) {
|
||||||
|
return new Promise(function (res) { setTimeout(function () { res(r); }, 700); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_pressing_a_clean_pick_during_a_batch_sends_nothing(browser, live):
|
||||||
|
"""kimi: a blank pick pressed while a batch was in flight found every
|
||||||
|
dirty form busy, fell to the one-form path, POSTed a blank and got the 400
|
||||||
|
— 'Could not save in place' at the moment the save was succeeding, and a
|
||||||
|
reload. The batch in flight already covers it: the press is a no-op."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b)
|
||||||
|
posts = []
|
||||||
|
page = browser.new_page()
|
||||||
|
page.add_init_script(_HOLD_POSTS)
|
||||||
|
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('#mark-a3 input[type=radio][value="no"]')
|
||||||
|
page.locator("#mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
page.locator("#mark-a2 .mark-submit").click()
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||||
|
page.wait_for_timeout(1200)
|
||||||
|
status_hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
page.close()
|
||||||
|
assert [p.split("&")[0] for p in posts] == ["ask=a1", "ask=a3"], posts
|
||||||
|
assert status_hidden and same, (status_hidden, same)
|
||||||
|
|
||||||
|
|
||||||
|
# The first page refresh after a save held 1s, so a batch queues behind a flag
|
||||||
|
# and the flag's swap lands while the batch is still waiting its turn.
|
||||||
|
_HOLD_FIRST_REFRESH_AND_POSTS = _HOLD_FIRST_REFRESH + _HOLD_POSTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pick_in_flight_stays_in_flight_across_another_saves_swap(browser, live):
|
||||||
|
"""hulda: the in-flight mark lived on the DOM node, and a queued flag's
|
||||||
|
swap replaced the node with an unmarked copy — so a second press built a
|
||||||
|
second batch and POSTed the same answers twice. In flight is now keyed by
|
||||||
|
the form's identity, which survives a swap."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 2)
|
||||||
|
_picks(b, ("a1", "a2"))
|
||||||
|
posts = []
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
page.add_init_script(_HOLD_FIRST_REFRESH_AND_POSTS)
|
||||||
|
page.on("request", lambda r: posts.append(r.post_data) if r.method == "POST" else None)
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||||
|
page.check('.verdict #mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('.verdict #mark-a2 input[type=radio][value="no"]')
|
||||||
|
page.locator(".verdict #mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_selector('figure.item.is-flagged[data-item="01.png"]', timeout=10000)
|
||||||
|
page.locator(".verdict #mark-a1 .mark-submit").click() # a fresh node, same form
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.verdict .mark-pick.is-answered').length === 2", timeout=15000)
|
||||||
|
page.wait_for_timeout(2500)
|
||||||
|
page.close()
|
||||||
|
asks = [p.split("&")[0] for p in posts if p.startswith("ask=")]
|
||||||
|
assert asks == ["ask=a1", "ask=a2"], posts
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_later_save_clears_a_stale_not_saved_line(browser, live):
|
||||||
|
"""A partial batch's "Not saved: a2" stays on screen until something saves
|
||||||
|
again — and then it must go, or it reads as current after a2 has saved."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b, ("a1", "a2"))
|
||||||
|
drop = {"a2": True}
|
||||||
|
page = browser.new_page()
|
||||||
|
page.route("**/b/g/answer", lambda route: route.abort()
|
||||||
|
if drop["a2"] and "ask=a2" in (route.request.post_data or "")
|
||||||
|
else route.continue_())
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('#mark-a2 input[type=radio][value="no"]')
|
||||||
|
page.locator("#mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 1", timeout=10000)
|
||||||
|
drop["a2"] = False
|
||||||
|
page.locator("#mark-a2 .mark-submit").click()
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||||
|
hidden = page.evaluate("document.querySelector('[data-region=\"status\"]').hidden")
|
||||||
|
page.close()
|
||||||
|
assert hidden, "the stale 'not saved' line outlived the save that fixed it"
|
||||||
|
assert _chosen(b) == {"a1": "yes", "a2": "no"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- round two of the flight window (heid bug-hunt, hulda, 2026-09-27) --------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_saved_notes_box_comes_back_empty(browser, live):
|
||||||
|
"""The form just sent comes back as the server renders it: a saved note's
|
||||||
|
box is EMPTY again. Nothing asserted it, so dropping the sent-form rule from
|
||||||
|
carry() survived its mutation (hulda) — and a box that kept its text would
|
||||||
|
post the same note twice on the next press."""
|
||||||
|
from booth.marks import marks_for
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.locator(".mark-add textarea").fill("said once")
|
||||||
|
page.locator(".mark-add button").click()
|
||||||
|
page.wait_for_selector(".mark-note", timeout=10000)
|
||||||
|
left = page.locator(".mark-add textarea").input_value()
|
||||||
|
page.close()
|
||||||
|
assert left == "", f"the saved note's text came back as a draft: {left!r}"
|
||||||
|
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["said once"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_change_to_a_sent_pick_during_the_flight_is_kept(browser, live):
|
||||||
|
"""hulda #1: carry() skipped EVERY control of a sent form, so a pick changed
|
||||||
|
after the press came back as the server's copy of the earlier one. A sent
|
||||||
|
form that changed since the press now carries like any unsent form."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b, ("a1", "a2"))
|
||||||
|
page = browser.new_page()
|
||||||
|
page.add_init_script(_HOLD_POSTS)
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('#mark-a2 input[type=radio][value="yes"]')
|
||||||
|
page.locator("#mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_timeout(150)
|
||||||
|
page.check('#mark-a1 input[type=radio][value="no"]') # after the press
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.querySelectorAll('.mark-pick.is-answered').length === 2", timeout=10000)
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
showing = page.is_checked('#mark-a1 input[type=radio][value="no"]')
|
||||||
|
page.close()
|
||||||
|
assert _chosen(b) == {"a1": "yes", "a2": "yes"}
|
||||||
|
assert showing, "the change made after the press was replaced by the saved copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_queued_note_is_not_carried_back_as_a_draft(browser, live):
|
||||||
|
"""hulda #3: a note queued behind a flag. The flag's swap replaced the note
|
||||||
|
form, so the note's own refresh named a detached node, the sent-form rule
|
||||||
|
missed the live one, and the saved text came back as a draft — one more
|
||||||
|
press and it posted twice. Sent forms now match by identity."""
|
||||||
|
from booth.marks import marks_for
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 2)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
page.add_init_script(_HOLD_FIRST_REFRESH)
|
||||||
|
page.goto(f"{base}/b/g/", wait_until="networkidle")
|
||||||
|
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
|
||||||
|
page.locator(".verdict .mark-add textarea").fill("queued once")
|
||||||
|
page.locator(".verdict .mark-add button").click()
|
||||||
|
page.wait_for_selector(".verdict .mark-note", timeout=10000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
left = page.locator(".verdict .mark-add textarea").input_value()
|
||||||
|
page.close()
|
||||||
|
assert left == "", f"the saved note came back as a draft: {left!r}"
|
||||||
|
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["queued once"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live):
|
||||||
|
"""hulda #4, and heid's H1: every pick saved, then the page GET failed —
|
||||||
|
and the batch took C3's reload, taking an unsent note with it. A batch
|
||||||
|
never reloads: it says the page could not be refreshed."""
|
||||||
|
base, root = live
|
||||||
|
b = _set(root, 1)
|
||||||
|
_picks(b, ("a1", "a2"))
|
||||||
|
gate = {"on": False}
|
||||||
|
page = browser.new_page()
|
||||||
|
page.route("**/b/g/marks", lambda route: route.abort()
|
||||||
|
if gate["on"] and route.request.method == "GET" else route.continue_())
|
||||||
|
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
|
||||||
|
gate["on"] = True
|
||||||
|
page.evaluate("window.__same = 1")
|
||||||
|
page.check('#mark-a1 input[type=radio][value="yes"]')
|
||||||
|
page.check('#mark-a2 input[type=radio][value="no"]')
|
||||||
|
page.locator(".mark-add textarea").fill("not sent")
|
||||||
|
page.locator("#mark-a1 .mark-submit").click()
|
||||||
|
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=10000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
said = page.locator('[data-region="status"]').inner_text()
|
||||||
|
same = page.evaluate("window.__same === 1")
|
||||||
|
draft = page.locator(".mark-add textarea").input_value()
|
||||||
|
page.close()
|
||||||
|
assert _chosen(b) == {"a1": "yes", "a2": "no"}
|
||||||
|
assert same and draft == "not sent", (same, draft)
|
||||||
|
assert "reload" in said.lower(), said
|
||||||
|
|||||||
Reference in New Issue
Block a user