fix: a wrong-shaped answer no longer 500s the gallery and the marks page
Pre-existing, measured at 42ea67f, so it predates U3. `_hydrate` checked only
that `answer` was a dict and never that `answer["answers"]` was one, so
`marks_for` and `hold_read` both reported the mark healthy with no read error
-- and `_ask_inline.html` then asked a list for `.get`. The v0.2.2 lesson was
half-implemented: that outage was a file that could not be PARSED and the
reader was made lenient, while this one parses perfectly and breaks one layer
further in, at render, where no leniency existed.
Closed at the hydration boundary rather than by a third copy of the guard --
one predicate, one place, every surface inherits it. Only the multi case is
checked, because only the multi case indexes; requiring `answers`
unconditionally would break every single-question pick, and that direction has
its own test. Measured before and after: gallery and marks pages 500 -> 200,
the error visible on the page, the booth's other healthy pick untouched.
The placement was the one open operator question of the session. It was
surfaced three times without a ruling, so it is taken under a stated assumption
and is cheap to move: the whole fix is one condition in one function.
Two things fell out of it worth more than the fix.
`_safe_fragments` no longer has a reachable natural trigger. Probed every wrong
answer shape a .marks.json can carry: `answers` as a list, a string or null all
become hydration errors now, and a wrong-typed value INSIDE `answers` renders
without raising, because Jinja absorbs attribute access on a non-mapping. U3's
guard is a pure backstop, and its test now says so and trips it synthetically
through the shared macro module rather than asserting a path nothing reaches.
A guard tested by an unreachable input is an untested guard.
And that guard's handler could not survive the failure it was handling: it
caught a raising `_pick_fragments` and rebuilt the broken-ask box through the
SAME macro module that had just raised, so whenever `whole` was the broken
thing it re-raised and took the whole report. Found by accident while building
the falsifier. Fixed, with its own test.
Both new falsifiers were verified RED against their defeating change rather
than assumed.
607 -> 611 tests.
This commit is contained in:
+95
-6
@@ -307,18 +307,107 @@ def test_a_wrongly_shaped_answer_costs_its_pick_not_the_report(client):
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
# THE PROMISE, NOT THE LAYER. This used to pin the string
|
||||
# `_safe_fragments` produces ("could not be rendered"), which made the test
|
||||
# an assertion about WHICH guard fired. As of the `_hydrate` answer-shape
|
||||
# check, this input is caught one layer earlier and never reaches
|
||||
# `_pick_fragments` at all — the endpoint's promise is unchanged and the
|
||||
# error is better (it names what is wrong with the stored answer instead of
|
||||
# reporting a render failure), so the assertion moved to the promise.
|
||||
# `_safe_fragments` is still the backstop and is still falsified, by
|
||||
# `test_safe_fragments_still_catches_what_hydration_cannot` below.
|
||||
assert by["batch"]["error"], "a wrong-shaped answer reported no error"
|
||||
assert "broken ask" in by["batch"]["whole"]
|
||||
# and the booth's other pick is untouched — one bad entry costs one entry
|
||||
assert by["healthy"]["error"] is None
|
||||
assert "Which render wins?" in by["healthy"]["whole"]
|
||||
assert c.get("/b/b/").status_code == 200
|
||||
|
||||
# ⚠ THE GALLERY AND MARKS PAGES STILL 500 ON THIS ENTRY, and that is NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before this unit. They render
|
||||
# the same macro without this guard. Out of scope here (the gallery is named
|
||||
# out of scope in the contract) and recorded rather than quietly widened:
|
||||
# see persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
# ⚠ THE GALLERY AND MARKS PAGES USED TO 500 ON THIS ENTRY, and that was NOT
|
||||
# U3's doing — measured at 42ea67f, the commit before that unit. CLOSED
|
||||
# 2026-09-22 at the hydration boundary rather than by a third copy of this
|
||||
# guard: see tests/test_marks.py
|
||||
# ::test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500 and
|
||||
# persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md
|
||||
|
||||
|
||||
def test_safe_fragments_still_catches_what_hydration_cannot(client):
|
||||
"""U3's `_safe_fragments` guard, kept falsifiable after `_hydrate` took its
|
||||
natural trigger away.
|
||||
|
||||
The answer-shape check in `_hydrate` now catches every wrong answer shape
|
||||
reachable from a `.marks.json` — probed 2026-09-22: `answers` as a list, a
|
||||
string or null all become hydration errors, and a wrong-typed VALUE inside
|
||||
`answers` renders without raising, because Jinja absorbs attribute access
|
||||
on a non-mapping. **No natural input reaches `_safe_fragments` by this
|
||||
route any more**, and a test that kept pretending one did would assert
|
||||
nothing — which is the failure this suite has now paid for twice.
|
||||
|
||||
So the trigger is synthetic and says so: the shared `_ask_inline` macro
|
||||
module is made to raise. `_pick_fragments` resolves `whole` off that object
|
||||
per call, and `create_app` stashes the environment on `app.state`, so this
|
||||
reaches the very object the closure captured. What it pins is the guard
|
||||
itself — one raising pick costs that pick, never the report.
|
||||
|
||||
Defeating change: removing the try/except in `_safe_fragments`, under which
|
||||
this returns 500.
|
||||
"""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
|
||||
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||
real_submit = frag.submit
|
||||
|
||||
def explode(*a, **k):
|
||||
raise RuntimeError("synthetic render failure")
|
||||
|
||||
object.__setattr__(frag, "submit", explode)
|
||||
try:
|
||||
assert frag.submit is explode, "the patch did not take; this test is vacuous"
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200, "a raising fragment renderer took the whole report"
|
||||
by = {m["id"]: m for m in r.json()["marks"]}
|
||||
assert by["batch"]["error"] and "could not be rendered" in by["batch"]["error"]
|
||||
assert by["batch"]["whole"], "the fallback rendered nothing at all"
|
||||
finally:
|
||||
object.__setattr__(frag, "submit", real_submit)
|
||||
|
||||
# and the guard is not sticky — with the macro restored, the pick is fine
|
||||
assert c.get("/b/b/embed.json").json()["marks"][0]["error"] is None
|
||||
|
||||
|
||||
def test_the_handler_survives_the_failure_it_is_handling(client):
|
||||
"""`_safe_fragments` caught a raising `_pick_fragments` and then rebuilt the
|
||||
broken-ask box THROUGH THE SAME MACRO MODULE that had just raised. So when
|
||||
`whole` itself was the broken thing, the handler re-raised and took the
|
||||
whole report — a guard that only worked when the failure was somewhere
|
||||
else.
|
||||
|
||||
Found by accident: the first draft of the falsifier above patched `whole`,
|
||||
and the guard failed rather than caught. Defeating change: removing the
|
||||
inner try/except, under which this returns 500."""
|
||||
c, data = client
|
||||
b = data / "b"
|
||||
b.mkdir(parents=True, exist_ok=True)
|
||||
declare_pick(b, "batch", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
(b / "index.html").write_text(DECLARED)
|
||||
|
||||
frag = c.app.state.templates.env.get_template("_ask_inline.html").module
|
||||
real_whole = frag.whole
|
||||
|
||||
def explode(*a, **k):
|
||||
raise RuntimeError("even the fallback macro is broken")
|
||||
|
||||
object.__setattr__(frag, "whole", explode)
|
||||
try:
|
||||
r = c.get("/b/b/embed.json")
|
||||
assert r.status_code == 200, "the handler re-raised through the broken macro"
|
||||
assert r.json()["marks"][0]["error"]
|
||||
finally:
|
||||
object.__setattr__(frag, "whole", real_whole)
|
||||
|
||||
|
||||
def test_no_regex_touches_author_html():
|
||||
|
||||
@@ -1374,3 +1374,57 @@ def test_a_clock_restore_that_fails_does_not_take_the_route_down(tmp_path):
|
||||
|
||||
assert (booth / MARKS_LOCK).exists()
|
||||
assert [m.target for m in marks_for(booth)] == ["a.png"]
|
||||
|
||||
|
||||
# ---- the wrong-shaped answer, closed at the hydration boundary --------------
|
||||
|
||||
|
||||
def test_a_wrong_shaped_answer_is_an_error_at_hydration_not_a_500(tmp_path):
|
||||
"""A `.marks.json` that is well-formed JSON with a wrong-shaped `answer`
|
||||
passed every reader and then raised in the TEMPLATE: `_hydrate` checked only
|
||||
that `answer` was a dict, never that `answer["answers"]` was one, so
|
||||
`marks_for` and `hold_read` both reported the mark healthy with no read
|
||||
error — and `_ask_inline.html` asked a list for `.get`.
|
||||
|
||||
Measured at `42ea67f`, so it predates U3. U3 guarded its own surface with
|
||||
`_safe_fragments` and left the gallery and marks pages alone by scope. This
|
||||
closes it at the boundary the rest of the module already argues for: ONE
|
||||
predicate, ONE place, every surface inherits it.
|
||||
|
||||
Defeating change: restoring the bare `isinstance(answer, dict)` check —
|
||||
under which `error` is None here and both pages 500.
|
||||
"""
|
||||
declare_pick(tmp_path, "batch", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]},
|
||||
{"key": "r2", "prompt": "B?", "options": ["x", "y"]}]})
|
||||
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "batch":
|
||||
e["answer"] = {"answers": [], "notes": ""}
|
||||
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||
|
||||
mark = {m.id: m for m in marks_for(tmp_path)}["batch"]
|
||||
assert mark.error, "a wrong-shaped answer hydrated as healthy"
|
||||
assert "answer" in mark.error
|
||||
# AND the mark is not silently emptied — the declaration survives, so the
|
||||
# operator can still see WHICH question broke rather than a bare error.
|
||||
assert mark.declaration is not None
|
||||
|
||||
|
||||
def test_a_healthy_multi_answer_still_hydrates(tmp_path):
|
||||
"""The other direction, so the guard cannot be satisfied by rejecting
|
||||
everything. Defeating change: requiring `answers` unconditionally, which
|
||||
would break every single-question pick."""
|
||||
declare_pick(tmp_path, "multi", {"title": "T", "questions": [
|
||||
{"key": "r1", "prompt": "A?", "options": ["x", "y"]}]})
|
||||
declare_pick(tmp_path, "single", {"prompt": "Which?", "options": ["x", "y"]})
|
||||
raw = json.loads((tmp_path / ".marks.json").read_text())
|
||||
for e in raw["marks"]:
|
||||
if e["id"] == "multi":
|
||||
e["answer"] = {"answers": {"r1": {"choice": "x", "notes": ""}}, "notes": ""}
|
||||
if e["id"] == "single":
|
||||
e["answer"] = {"choice": "x", "notes": ""}
|
||||
(tmp_path / ".marks.json").write_text(json.dumps(raw))
|
||||
by = {m.id: m for m in marks_for(tmp_path)}
|
||||
assert by["multi"].error is None, by["multi"].error
|
||||
assert by["single"].error is None, by["single"].error
|
||||
|
||||
Reference in New Issue
Block a user