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:
+32
-3
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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;'''
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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; }'''
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user