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
+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