fix(r3): judge each rel once per request — a side or review item that vanishes mid-request never 500s
booth-dev's race note after the merge: the compare route resolved each side in _compare_side and again in _compare_ring, then ring.index(a) raised if the file vanished (or was relinked outside the booth) between the two; the review did the same through cring.index(f). The compare ring is now built once and the sides are judged by membership of it. The review re-judges its item and scans forward for the next comparable one (usually one step, no longer a resolve of the whole ring per render); an item no longer comparable renders the review without a Compare control, and C does nothing. The contract records the once-per-request rule and that the phone-width wrap covers doc.html's bar too. r3.toml: 59 rows, four re-anchored.
This commit is contained in:
+32
-8
@@ -19,11 +19,9 @@ label = "C1 the conjunction loses the ring (a doc or a sidecar opens as a side)"
|
||||
file = "booth/app.py"
|
||||
test = "tests/test_compare.py::test_a_bad_side_is_a_404"
|
||||
old = '''
|
||||
if rel not in ring:
|
||||
raise HTTPException(status_code=404, detail="no such item")
|
||||
return rel'''
|
||||
return [r for r in review_chain(items) if _in_booth(booth, r)]'''
|
||||
new = '''
|
||||
return rel'''
|
||||
return [it.rel for it in items if _in_booth(booth, it.rel)]'''
|
||||
|
||||
[[mutation]]
|
||||
label = "C1 a missing side is FastAPI's 422 (no default)"
|
||||
@@ -48,12 +46,12 @@ test = "tests/test_compare.py::test_a_look_records_both_seen"
|
||||
old = '''
|
||||
booth = resolve_booth(name)
|
||||
items = booth_items(booth)
|
||||
a = _compare_side(booth, review_chain(items), a)'''
|
||||
ring = _compare_ring(booth, items)'''
|
||||
new = '''
|
||||
booth = resolve_booth(name)
|
||||
record_view(booth)
|
||||
items = booth_items(booth)
|
||||
a = _compare_side(booth, review_chain(items), a)'''
|
||||
ring = _compare_ring(booth, items)'''
|
||||
|
||||
[[mutation]]
|
||||
label = "C6 compare does not carry data-booth (Reveal all and its restore bail)"
|
||||
@@ -114,8 +112,8 @@ new = ''' side_a = side not in ("", "b")'''
|
||||
label = "C2 the review's Compare does not wrap (the last item compares with itself)"
|
||||
file = "booth/app.py"
|
||||
test = "tests/test_compare.py::test_the_review_offers_compare_with_the_next_item"
|
||||
old = ''' f"&b={quote(cring[(cring.index(f) + 1) % len(cring)], safe='/')}"),'''
|
||||
new = ''' f"&b={quote(cring[min(cring.index(f) + 1, len(cring) - 1)], safe='/')}"),'''
|
||||
old = '''(ring[(pos + k) % len(ring)] for k in range(1, len(ring) + 1))'''
|
||||
new = '''(ring[min(pos + k, len(ring) - 1)] for k in range(1, len(ring) + 1))'''
|
||||
|
||||
# ---- C5: the regions and the JS-off flag landing
|
||||
|
||||
@@ -543,6 +541,32 @@ old = '''
|
||||
new = '''
|
||||
return p && !/^(side|link)(=|$)/.test(p);'''
|
||||
|
||||
# ---- after the merge: booth-dev's race note (01M3AT7GKCPATJD5YW0PR3SRPT)
|
||||
|
||||
[[mutation]]
|
||||
label = "C1 a side is judged twice (the ring rebuilt per side): a side that vanishes between is a 500"
|
||||
file = "booth/app.py"
|
||||
test = "tests/test_compare.py::test_a_side_that_vanishes_mid_request_never_500s"
|
||||
old = '''
|
||||
ring = _compare_ring(booth, items) # built ONCE; every rel judged once
|
||||
a = _compare_side(ring, a)
|
||||
b = _compare_side(ring, b)'''
|
||||
new = '''
|
||||
a = _compare_side(_compare_ring(booth, items), a)
|
||||
b = _compare_side(_compare_ring(booth, items), b)
|
||||
ring = _compare_ring(booth, items)'''
|
||||
|
||||
[[mutation]]
|
||||
label = "C2 the review offers Compare for an item that vanished after its own check"
|
||||
file = "booth/app.py"
|
||||
test = "tests/test_compare.py::test_the_review_hides_compare_when_its_item_vanishes_mid_request"
|
||||
old = '''
|
||||
if _in_booth(booth, f):
|
||||
partner'''
|
||||
new = '''
|
||||
if True:
|
||||
partner'''
|
||||
|
||||
# Refuted, not rowed (bug hunt): "a right-click mid-drag ends the pan" — a
|
||||
# second button pressed and released during a drag arrives as chorded
|
||||
# `pointermove` events, never a `pointerup` (measured 3/3 in the test browser,
|
||||
|
||||
@@ -165,6 +165,54 @@ def test_a_planted_fifo_marker_cannot_hang_a_look(tmp_path):
|
||||
assert got == [200], "a planted FIFO held the look open"
|
||||
|
||||
|
||||
def _vanish_after_scan(monkeypatch, name: str, grace: int) -> None:
|
||||
"""Make `name` stop being a file partway through a request: once
|
||||
booth_items has scanned the booth, the first `grace` is_file checks of it
|
||||
still pass and every later one fails — a file deleted or relinked outside
|
||||
the booth mid-request, between two resolves of the same rel."""
|
||||
import pathlib as _pl
|
||||
import booth.app as app_mod
|
||||
state = {"armed": False, "calls": 0}
|
||||
real_items, real_is_file = app_mod.booth_items, _pl.Path.is_file
|
||||
|
||||
def items(booth):
|
||||
out = real_items(booth)
|
||||
state["armed"] = True
|
||||
return out
|
||||
|
||||
def is_file(self):
|
||||
if state["armed"] and self.name == name:
|
||||
state["calls"] += 1
|
||||
if state["calls"] > grace:
|
||||
return False
|
||||
return real_is_file(self)
|
||||
|
||||
monkeypatch.setattr(app_mod, "booth_items", items)
|
||||
monkeypatch.setattr(_pl.Path, "is_file", is_file)
|
||||
|
||||
|
||||
def test_a_side_that_vanishes_mid_request_never_500s(tmp_path, monkeypatch):
|
||||
"""booth-dev's race: each rel must be judged ONCE per request. A side that
|
||||
passes its check and then vanishes before a second resolve must not reach
|
||||
a `.index()` that raises — a damaged file costs its own tile, never the
|
||||
page."""
|
||||
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG})
|
||||
_vanish_after_scan(monkeypatch, "p.png", grace=1)
|
||||
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png")
|
||||
assert r.status_code in (200, 404), r.status_code
|
||||
|
||||
|
||||
def test_the_review_hides_compare_when_its_item_vanishes_mid_request(tmp_path, monkeypatch):
|
||||
"""The review checked its item, then the item vanished before the compare
|
||||
ring was built: the page still renders, without a Compare control (a
|
||||
compare of it would 404) — never a 500."""
|
||||
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
|
||||
_vanish_after_scan(monkeypatch, "p.png", grace=0)
|
||||
r = _client(tmp_path).get("/b/g/view?f=p.png")
|
||||
assert r.status_code == 200, r.status_code
|
||||
assert 'class="vbtn vcompare"' not in r.text
|
||||
|
||||
|
||||
def test_a_look_records_both_seen(tmp_path):
|
||||
"""A compare GET is a look at both sides; a 404 records nothing."""
|
||||
import json
|
||||
|
||||
Reference in New Issue
Block a user