diff --git a/.gitignore b/.gitignore index 3312be7..4d7a468 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ graphify-out/ # scripts/mutation_check.py crash marker — never committed .mutation-inflight +.mutation-lock diff --git a/booth/static/embed.js b/booth/static/embed.js index ccdf0f9..23c5d28 100644 --- a/booth/static/embed.js +++ b/booth/static/embed.js @@ -460,20 +460,63 @@ 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"); + /* THE REPORT'S OWN INPUTS, which a reload or a navigation of ours would + clear. Measured against how each stood WHEN WE MOUNTED, never against its + default attributes: a bare ' + f"{SEAM}") + page = _open(browser, base, "b", html, b) + page.wait_for_selector("#bk-ask-a2-submit") + page.evaluate("window.__same = 1") + posts = _watch_posts(page) + page.fill("#scratch", "my own working") + _choice(page, "a1", "yes") + _press(page, "a1") + page.wait_for_selector("#bk-ask-a1-submit .bk-ask-status:not([hidden])", timeout=10000) + same = page.evaluate("window.__same === 1") + kept = page.input_value("#scratch") + page.close() + assert posts == [("fetch", "a1")], posts + assert same and kept == "my own working", (same, kept) + assert _answers(b)["a1"]["choice"] == "yes" + + +def test_untouched_report_controls_do_not_hold_the_reload(browser, live): + """SPYRJA H7 / heid V1: a bare " + '' + f"{SEAM}") + page = _open(browser, base, "b", html, b) + page.wait_for_selector("#bk-ask-a2-submit") + _choice(page, "a1", "yes") + _choice(page, "a2", "no") + with page.expect_navigation(timeout=10000): + _press(page, "a1") + page.close() + assert {k: v["choice"] for k, v in _answers(b).items()} == {"a1": "yes", "a2": "no"} + + +def test_a_clean_batch_does_not_reload_over_an_edited_contenteditable(browser, live): + """SPYRJA H2 / heid V2: text typed into a report's contenteditable region + is input too, and the reload took it.""" + base, data = live + b = _asks(data / "b", ("a1", "a2")) + html = ("r" + '
first
' + f"{SEAM}") + page = _open(browser, base, "b", html, b) + page.wait_for_selector("#bk-ask-a2-submit") + page.evaluate("window.__same = 1") + page.click("#ed") + page.keyboard.press("End") + page.keyboard.type(" and more") + _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) + same = page.evaluate("window.__same === 1") + text = page.inner_text("#ed") + page.close() + assert same and text == "first and more", (same, text) diff --git a/tests/test_mutation_check.py b/tests/test_mutation_check.py index 646642c..5546585 100644 --- a/tests/test_mutation_check.py +++ b/tests/test_mutation_check.py @@ -142,3 +142,73 @@ def test_an_ambiguous_anchor_is_reported_not_guessed(tmp_path): "old": " x = 2\n", "new": " x = 3\n"}, repo=repo) assert not proved assert "ambiguous" in note + + +# ---- SPYRJA (heid bug-hunt, hulda, 2026-09-28): the instrument's own edges -- + + +def test_a_mutation_that_stops_the_test_running_is_not_a_proof(tmp_path): + """A mutation that breaks collection (a syntax error) exits non-zero + without the assertion ever running. `rc != 0` certified that as PROVED: + the tool's one claim, that the test caught the change, was never tested.""" + repo = _tree(tmp_path, "def f():\n return 2\n", + "def test_f():\n assert f() == 2\n") + proved, note = check( + {"label": "syntax", "file": "mod.py", "test": "test_probe.py::test_f", + "old": "return 2", "new": "return ("}, repo=repo) + assert not proved + assert "did not run" in note + + +def test_an_overlapping_anchor_is_ambiguous_too(tmp_path): + """`str.count` counts NON-overlapping matches: `"aaa".count("aa") == 1` + while `aa` starts at two places. The ambiguity guard must see both.""" + repo = _tree(tmp_path, 'def f():\n return len("aaa")\n', + "def test_f():\n assert f() == 3\n") + proved, note = check( + {"label": "overlap", "file": "mod.py", "test": "test_probe.py::test_f", + "old": "aa", "new": "b"}, repo=repo) + assert not proved + assert "ambiguous" in note + + +def test_a_crlf_source_is_restored_byte_for_byte(tmp_path): + """Text-mode I/O read CRLF as LF and wrote LF back, then compared LF with + LF and called it restored — and reset the mtime so nothing looked touched.""" + repo = _tree(tmp_path, "def f():\n return 2\n", + "def test_f():\n assert f() == 2\n") + mod = repo / "mod.py" + mod.write_bytes(b"def f():\r\n return 2\r\n") + before = mod.read_bytes() + check({"label": "flip", "file": "mod.py", "test": "test_probe.py::test_f", + "old": "return 2", "new": "return 3"}, repo=repo) + assert mod.read_bytes() == before + + +def test_a_mutation_that_hangs_is_stopped_and_the_source_restored(tmp_path): + """No timeout meant a mutation that loops forever held the checker — and + the mutated file — until someone killed it by hand.""" + repo = _tree(tmp_path, "def f():\n return 2\n", + "def test_f():\n assert f() == 2\n") + before = (repo / "mod.py").read_text() + proved, note = check( + {"label": "hang", "file": "mod.py", "test": "test_probe.py::test_f", + "old": "return 2", "new": "while True: pass"}, repo=repo, timeout=10) + assert not proved + assert "timed out" in note.lower() + assert (repo / "mod.py").read_text() == before + + +def test_a_second_run_is_refused_while_one_holds_the_repo(tmp_path, monkeypatch): + """Two checkers interleaving on one tree can restore each other's + mutation back in. One run at a time, by lock.""" + import fcntl + import mutation_check as mc + + monkeypatch.setattr(mc, "REPO", tmp_path) + monkeypatch.setattr(mc, "INFLIGHT", tmp_path / ".mutation-inflight") + monkeypatch.setattr(mc, "TABLES", tmp_path / "tables") + (tmp_path / "tables").mkdir() + with open(tmp_path / ".mutation-lock", "w") as held: + fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert mc.main(["mutation_check.py"]) == 2