fix(inplace,embed): input set back mid-flight, report inputs, ambiguous anchors

Four items owed after S5b, reported by design-dev during the anti-slop run:

- carry() measured a sent-then-changed form against its OLD DEFAULTS. An
  answer set back mid-flight to the value the page first showed read as
  untouched, and the swap put the just-saved value over it. A form sent and
  then changed is now measured against its sent snapshot (sentSet.snapOf).
- The embed's clean-batch reload saw only our own forms. A report's own
  inputs lost whatever the operator had typed into them. Unsaved text in
  any control we don't own now holds the reload, and the page says so.
- Two r2b.toml rows ("D3 a stored theme...", "D3 forced light...") matched
  twice, so they proved only by where the first match fell. Both are
  re-anchored, and scripts/mutation_check.py now refuses any anchor that
  matches more than once. A new tool control covers that.
- The r2_flow contract's C3 steps 2 and 4 now say what S5b superseded. U3
  gains the report-input rule.

Mutation rows: u3_submit_all +1, r2_submit_all +1. Four rows were
re-anchored onto the moved lines.
This commit is contained in:
vh
2026-09-28 16:52:42 -07:00
parent a22a00b82e
commit 377e652670
13 changed files with 194 additions and 32 deletions
+32 -3
View File
@@ -460,6 +460,28 @@
return out;
}
function hostDirty(forms) {
/* Any control NOT owned by one of our forms that differs from its default:
the report's own inputs, which a reload would clear. */
var els = document.querySelectorAll("input, textarea, select");
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.form && forms.indexOf(el.form) >= 0) continue;
var type = (el.type || "").toLowerCase();
if (/^(hidden|submit|button|reset|image)$/.test(type)) continue;
if (type === "radio" || type === "checkbox") {
if (el.checked !== el.defaultChecked) return true;
} else if (el.tagName === "SELECT") {
for (var j = 0; j < el.options.length; j++) {
if (el.options[j].selected !== el.options[j].defaultSelected) return true;
}
} else if (el.value !== el.defaultValue) {
return true;
}
}
return false;
}
function askOf(form) {
var els = form.elements;
for (var i = 0; i < els.length; i++) {
@@ -517,15 +539,22 @@
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; }
/* ...and the REPORT's own inputs: the reload would take whatever the
operator typed into the author's page too (design-dev's report,
2026-09-28). ourForms cannot see those, so this looks at the rest. */
var host = hostDirty(forms);
if (!failed.length && !changed && !host) { 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.";
: changed
? "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."
: "Saved " + saved + " of " + batch.length + ". The page was not reloaded, because " +
"something else on it holds text you have not saved; reload when you are ready.";
st.hidden = false;
});
});
+20 -6
View File
@@ -1295,9 +1295,13 @@
if (el.type === 'hidden' || isSent(el.form)) return;
var t = freshFields[fieldKey(el)];
if (!t) return;
/* The baseline: what a sent-then-changed form SENT; else the default. */
var snap = isSent.snapOf ? isSent.snapOf(el.form) : null;
var was = snap === null ? null : new URLSearchParams(snap);
if (el.type === 'radio' || el.type === 'checkbox') {
if (el.checked !== el.defaultChecked) t.checked = el.checked;
} else if (el.value !== el.defaultValue) {
var on = was ? was.getAll(el.name).indexOf(el.value) >= 0 : el.defaultChecked;
if (el.checked !== on) t.checked = el.checked;
} else if (el.value !== (was ? (was.get(el.name) || '') : el.defaultValue)) {
t.value = el.value;
}
});
@@ -1428,14 +1432,24 @@
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;
function snapOf(f) {
if (!f) return null;
var k = flightKey(f);
for (var i = 0; i < recs.length; i++) {
if (recs[i].key === k && recs[i].snap === serial(f)) return true;
if (recs[i].key === k) return recs[i].snap;
}
return false;
return null;
}
var isSent = function (f) {
var snap = snapOf(f);
return snap !== null && snap === serial(f);
};
/* A sent form that CHANGED after its press is carried — measured against
what it sent, not its old defaults, or an answer set back to the
value the page first showed would read as untouched and the swap
would put the saved one over it (design-dev's report, 2026-09-28). */
isSent.snapOf = snapOf;
return isSent;
}
/* Resolves true when the fresh page was placed. It never reloads: the
caller decides what an unplaced page means (`keep`, a batch, says so;
+19 -4
View File
@@ -221,7 +221,11 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
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.
back as a draft. A sent form that changed after its press is carried
against what it SENT, not its old defaults (amended 2026-09-28): an
answer set back mid-flight to the value the page first showed would
otherwise read as untouched, and the swap would put the saved value over
the operator's last word.
3. **Saves are SERIALIZED.** Each save runs its POST, its GET and its swap
before the next begins, so an older snapshot never lands after a newer one
(three quick flags show three flags). A form already queued or in flight
@@ -285,11 +289,22 @@ today's zoom flag form carries no `back`, so it lands on the gallery.
`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.
form as sent and `test_a_saved_notes_box_comes_back_empty` fails; measure
a sent-then-changed form against its old defaults and
`test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept` fails.
4. **The script never re-POSTs.** A retry after a lost response would re-apply
the judgment: a duplicate note, or a re-dated answer.
- On a non-204 HTTP response, or a network failure, it writes a fixed
message into the page's server-rendered status element
- ⚠ **SUPERSEDED IN PART by `as_antislop.contract.md` S5b (merged
2026-09-28, `0233ca6`)**, which owns the status line and the failure
path from there on. What changed: the status line is never `hidden` (it
floats, and every save says "Saving…" / "Saved." with a warn tone for a
failure); and a failed one-form save reloads ONLY when no in-place form
holds a draft (the sent one excepted while it still equals its press),
re-checked at the beat. Otherwise it says so and keeps the page. Leaving
a page with an unsent draft asks first. What did not change: no re-POST,
ever, and the words are about the script's own requests only.
- As first written: on a non-204 HTTP response, or a network failure, it
writes a fixed message into the page's server-rendered status element
(`data-region="status"`, via textContent). After a beat (0.9 s, so the
words can be read) it reloads the page with a GET, so what you see is the
server's truth.
@@ -279,6 +279,10 @@ submit on one of our forms F:
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.
So does unsaved input in the REPORT'S OWN controls (amended
2026-09-28): any input, textarea or select not owned by one of our
forms that differs from its default. The reload would clear it, and
ourForms cannot see it.
```
Five consequences, each deliberate:
@@ -440,7 +444,9 @@ 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.
`test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty` fails; ignore
the report's own inputs and
`test_a_clean_batch_does_not_reload_over_text_typed_into_the_report` fails.
## Out of scope (deferred or never)
+8 -9
View File
@@ -26,15 +26,14 @@ _As of 2026-09-27:_
A hulda hunt on S3-S5a found 6 (release unasked, the Wipe-now guard moved to
the footer, a `__proto__` trapdoor, id collisions); all folded. The confirm
helper for wipe/release now lives in base.html's <head>.
**Still owed by booth-dev, after S5b lands (it rewrites the same code):**
(a) the embed's clean-batch reload can take text typed into the HOST page;
(b) carry() loses an edit set back to its original default mid-flight;
(c) two r2b.toml anchors match twice ("D3 a stored theme…", "D3 forced
light…") and prove only by where the first match falls; (d) the r2_flow
contract's C3 steps 2-4 are stale against S5b (status line never hidden,
one-form failure no longer reloads over a draft) — amend to point at
as_antislop S5b. **S5b MERGED AND PUSHED** (`0233ca6`, operator "merge and
push", 2026-09-28; booth-dev gate 1068 / 428/428 + seam pass clean): focus
**The four items booth-dev owed after S5b are DONE (2026-09-28):** the
embed's clean-batch reload now holds for unsaved text in the report's own
inputs; carry() measures a sent-then-changed form against what it SENT (an
answer set back mid-flight survives); the two double-matching r2b anchors
are re-anchored AND `scripts/mutation_check.py` now refuses any anchor that
matches more than once; the r2_flow C3 prose points at as_antislop S5b.
**S5b MERGED AND PUSHED** (`0233ca6`, operator "merge and push",
2026-09-28; booth-dev gate 1068 / 428/428 + seam pass clean): focus
survives a swap, a floating status line, and leaving with an unsent answer
ASKS FIRST (beforeunload, both surfaces). S5c (keys, doc bar, and the
refused-batch words that a later "Saved." can bury) is design-dev's next.
+7 -1
View File
@@ -80,8 +80,14 @@ def check(mutation: dict, repo: Path = REPO) -> tuple[bool, str]:
return False, f"BASELINE RED — {test} fails BEFORE the mutation"
src = path.read_text()
if mutation["old"] not in src:
hits = src.count(mutation["old"])
if hits == 0:
return False, f"anchor not found in {mutation['file']} — the table has drifted"
if hits > 1:
# The replace below takes the FIRST match, so an anchor that matches
# twice proves by where that match happens to fall, not by the line the
# row names. Two r2b rows proved that way until 2026-09-28.
return False, f"anchor is ambiguous — it matches {hits} places in {mutation['file']}"
INFLIGHT.write_text(f"{path}\n")
stat = path.stat() # mtime included; see the restore below
+1 -1
View File
@@ -126,7 +126,7 @@ new = ''''''
label = 'C3 client: an unsent radio is not carried across a swap'
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once"
old = ''' if (el.checked !== el.defaultChecked) t.checked = el.checked;'''
old = ''' if (el.checked !== on) t.checked = el.checked;'''
new = ''''''
[[mutation]]
+13 -4
View File
@@ -96,15 +96,24 @@ 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;'''
return snap !== null && snap === serial(f);'''
new = '''
if (recs[i].key === k) return true;'''
return snap !== null;'''
[[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;'''
if (recs[i].key === k) return recs[i].snap;'''
new = '''
if (false) return true;'''
if (false) return recs[i].snap;'''
[[mutation]]
label = "a sent-then-changed form is measured against its OLD defaults (a value set back mid-flight is lost)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept"
old = '''
var snap = isSent.snapOf ? isSent.snapOf(el.form) : null;'''
new = '''
var snap = null;'''
+4
View File
@@ -384,8 +384,10 @@ label = "D3 a stored theme is not applied at load (a reload forgets it)"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
var t = localStorage.getItem('booth.theme');
if (t === 'light' || t === 'dark') d.setAttribute('data-theme', t);'''
new = '''
var t = localStorage.getItem('booth.theme');
if (false) d.setAttribute('data-theme', t);'''
[[mutation]]
@@ -402,8 +404,10 @@ label = "D3 forced light is not in the sheet"
file = "booth/templates/_svos_tokens.css"
test = "tests/test_flow_browser.py::test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live"
old = '''
/* ... or the viewer forced light. Same declarations as above, by construction. */
:root[data-theme="light"] {'''
new = '''
/* ... or the viewer forced light. Same declarations as above, by construction. */
:root[data-theme="light-OFF"] {'''
[[mutation]]
+12 -3
View File
@@ -67,7 +67,7 @@ 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; }'''
if (!failed.length && !changed && !host) { location.reload(); return; }'''
new = '''
location.reload(); return;'''
@@ -76,9 +76,9 @@ label = "only dirtiness blocks the reload (a refused form set back to its first
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; }'''
if (!failed.length && !changed && !host) { location.reload(); return; }'''
new = '''
if (!changed) { location.reload(); return; }'''
if (!changed && !host) { location.reload(); return; }'''
[[mutation]]
label = "a refusal is never said"
@@ -139,3 +139,12 @@ test = "tests/test_embed_browser.py::test_the_empty_status_line_stays_hidden_und
old = '''
".bk-ask-status[hidden]{display:none}",'''
new = ''''''
[[mutation]]
label = "the reload ignores the report's own inputs (text typed into the host page is lost)"
file = "booth/static/embed.js"
test = "tests/test_embed_browser.py::test_a_clean_batch_does_not_reload_over_text_typed_into_the_report"
old = '''
if (!failed.length && !changed && !host) { location.reload(); return; }'''
new = '''
if (!failed.length && !changed) { location.reload(); return; }'''
+27
View File
@@ -1031,3 +1031,30 @@ def test_a_refusal_blocks_the_reload_even_when_nothing_reads_dirty(browser, live
page.close()
assert same, "the page reloaded over a refused POST"
assert shown, "the refusal was never said"
def test_a_clean_batch_does_not_reload_over_text_typed_into_the_report(browser, live):
"""design-dev's report, 2026-09-28: a successful batch reloaded whenever
none of OUR forms was dirty — but a report can carry inputs of its own, and
the reload took whatever the operator had typed into them. Unsaved input
anywhere on the page now holds the reload, and the page says so."""
base, data = live
b = _asks(data / "b", ("a1", "a2"))
html = ("<!doctype html><title>r</title><body>"
'<textarea id="scratch"></textarea>'
f"{SEAM}</body>")
page = _open(browser, base, "b", html, b)
page.wait_for_selector("#bk-ask-a2-submit")
page.evaluate("window.__same = 1")
page.fill("#scratch", "my own working")
_choice(page, "a1", "yes")
_choice(page, "a2", "no")
_press(page, "a1")
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()
same = page.evaluate("window.__same === 1")
kept = page.input_value("#scratch")
page.close()
assert same and kept == "my own working", (same, kept)
assert "reload" in said.lower(), said
assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"}
+30
View File
@@ -1819,3 +1819,33 @@ def test_a_batch_whose_refresh_fails_keeps_the_page(browser, live):
assert _chosen(b) == {"a1": "yes", "a2": "no"}
assert same and draft == "not sent", (same, draft)
assert "reload" in said.lower(), said
def test_a_sent_pick_set_back_to_its_first_value_mid_flight_is_kept(browser, live):
"""design-dev's report, 2026-09-28: carry() measured a changed-after-press
form against its OLD DEFAULTS, so an answer set back to the value the page
first showed looked untouched — and the swap put the just-saved value
back over the operator's last word, silently. A sent form that changed
since its press is now measured against what it SENT."""
from booth.marks import answer_pick
base, root = live
b = _set(root, 1)
_picks(b, ("a1",))
answer_pick(b, "a1", "no")
page = browser.new_page()
page.add_init_script(_HOLD_POSTS)
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.locator("#mark-a1 summary.mark-change").click()
page.check('#mark-a1 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"]') # back, mid-flight
page.wait_for_function(
"document.querySelector('#mark-a1 .mark-answer-choice') && "
"document.querySelector('#mark-a1 .mark-answer-choice').textContent.trim() === 'yes'",
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"}
assert showing, "the value set back mid-flight was replaced by the saved one"
+14
View File
@@ -128,3 +128,17 @@ def test_a_reverted_file_keeps_its_mtime(tmp_path):
check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f",
"old": "return 2", "new": "return 3"}, repo=repo)
assert mod.stat().st_mtime_ns == before
def test_an_ambiguous_anchor_is_reported_not_guessed(tmp_path):
"""An `old` that matches twice mutates whichever comes FIRST, so the row
proves or fails by where the first match happens to fall rather than by
the line it names. design-dev found two r2b rows proving that way
(2026-09-28). A row must name exactly one place."""
repo = _tree(tmp_path, "def f():\n x = 2\n x = 2\n return x\n",
"def test_f():\n assert f() == 2\n")
proved, note = check(
{"label": "twice", "file": "mod.py", "test": "test_probe.py::test_f",
"old": " x = 2\n", "new": " x = 3\n"}, repo=repo)
assert not proved
assert "ambiguous" in note