Files
vh d54bb04414 fix(r3): a NUL in the raw file path is a 404, not a 500
Compare's stages load their pictures through the catch-all file route, which
caught only OSError around resolve(); an embedded NUL raises ValueError. Same
class as resolve_booth's fix in f8d136a (heid bug hunt on the race fix,
hulda). The upload route's NUL-in-filename 500 is the same class and is left
to booth-dev: it is not on compare's path.
2026-09-24 16:33:27 -07:00

597 lines
22 KiB
TOML

# R3 — compare: every falsifier the contract claims
# (docs/contracts/r3_compare.contract.md), and the change each forbids.
unit = "compare: two picked rels side by side, linked stepping, synced pan, flag the winner"
# ---- C1: the route and the pair
[[mutation]]
label = "C1 the conjunction loses containment (an outside symlink in the ring opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
return str(target).startswith(str(booth) + os.sep) and target.is_file()'''
new = '''
return target.is_file()'''
[[mutation]]
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 = '''
return [r for r in review_chain(items) if _in_booth(booth, r)]'''
new = '''
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)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_missing_param_is_404_not_422"
old = '''def booth_compare(request: Request, name: str, a: str = "", b: str = "",'''
new = '''def booth_compare(request: Request, name: str, a: str, b: str,'''
[[mutation]]
label = "C1 a look records only A"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
record_seen(booth, b, items)'''
new = '''
'''
[[mutation]]
label = "C1 the look is recorded above the 404s (a bad pair holds a booth open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_look_records_both_seen"
old = '''
booth = resolve_booth(name)
items = booth_items(booth)
ring = _compare_ring(booth, items)'''
new = '''
booth = resolve_booth(name)
record_view(booth)
items = booth_items(booth)
ring = _compare_ring(booth, items)'''
[[mutation]]
label = "C6 compare does not carry data-booth (Reveal all and its restore bail)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_compare_carries_data_booth"
old = '''{% block html_attrs %} data-booth="{{ name }}"{% endblock %}'''
new = '''{% block html_attrs %}{% endblock %}'''
# ---- C2/C3: picking and stepping, server-built
[[mutation]]
label = "C3 linked steps do not keep the distance (B lands one after A)"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[(ia + 1) % n], ring[(ia + 2) % n]),'''
[[mutation]]
label = "C3 linked steps stop at the end instead of wrapping"
file = "booth/app.py"
test = "tests/test_compare.py::test_linked_steps_keep_the_distance_and_wrap"
old = ''' "both_next": url(ring[(ia + 1) % n], ring[(ib + 1) % n]),'''
new = ''' "both_next": url(ring[min(ia + 1, n - 1)], ring[min(ib + 1, n - 1)]),'''
[[mutation]]
label = "INV-1 a link is keyed by ordinal"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}&i={by_rel[x].ordinal}"'''
[[mutation]]
label = "INV-1 a rel is not url-quoted in a link"
file = "booth/app.py"
test = "tests/test_compare.py::test_the_urls_are_keyed_by_rel"
old = ''' u = f"/b/{name_url}/compare?a={quote(x, safe='/')}&b={quote(y, safe='/')}"'''
new = ''' u = f"/b/{name_url}/compare?a={x}&b={y}"'''
[[mutation]]
label = "C2 the view state does not ride the links"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = '''
if not linked:
u += "&link=0"
return u'''
new = '''
return u'''
[[mutation]]
label = "C2 an unknown side reads as A"
file = "booth/app.py"
test = "tests/test_compare.py::test_view_state_rides_the_links"
old = ''' side_a = side == "a"'''
new = ''' side_a = side not in ("", "b")'''
[[mutation]]
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 = '''(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
[[mutation]]
label = "C5 the side regions share one id (B's control becomes A's after a save)"
file = "booth/templates/compare.html"
test = "tests/test_compare.py::test_no_data_region_repeats"
old = '''<div class="cmp-flag" data-region="flag-{{ key }}">'''
new = '''<div class="cmp-flag" data-region="flag">'''
[[mutation]]
label = "C5 back=compare lands on a pair outside the ring (no ring check)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if a in ring and b in ring:'''
new = '''
if True:'''
[[mutation]]
label = "C5 the landing echoes the form's side instead of mapping it"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
if form.get("side") == "a":
url += "&side=a"'''
new = '''
if form.get("side"):
url += "&side=" + str(form.get("side"))'''
[[mutation]]
label = "C5 the landing carries a fragment"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_flag_without_js_lands_on_the_same_pair"
old = '''
return RedirectResponse(url=url, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
new = '''
return RedirectResponse(url=url + "#" + anchor, status_code=303)
return RedirectResponse(url=f"{base}#{anchor}", status_code=303)'''
# ---- C4: the stages
[[mutation]]
label = "C4 the sides stack on a wide screen (compare's break is not the review's 900px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
# Not the column count: each side spans every row (subgrid), so a single
# declared column still auto-places the second side into an implicit column
# beside the first — that row was vacuous. The break is what a slip moves.
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1600px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a caption takes its height from one stage only (Fit draws the two at two scales)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:subgrid;'''
new = '''
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:auto minmax(0,1fr) auto;'''
[[mutation]]
label = "C4 stacked, a stage keeps the review's 60vh"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
.review.compare .vstage{height:45vh}'''
new = '''
'''
[[mutation]]
label = "C4 Z is not bound (the mode key is missing on compare)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_one_mode_for_both_and_for_the_review"
old = '''
else if ((k === 'z' || k === 'Z') && mode) mode.flip();'''
new = '''
'''
[[mutation]]
label = "C4 no synced pan"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_lands_on_the_same_crop"
old = '''
sync(s.el, other);'''
new = '''
'''
[[mutation]]
label = "C4 the sync copies the pixel offset, not the fraction"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (fx > 0 && tx > 0) l = from.scrollLeft / fx * tx;
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (fx > 0 && tx > 0) l = from.scrollLeft;
if (fy > 0 && ty > 0) t = from.scrollTop;'''
[[mutation]]
label = "C4 the synced-pan loop guard is gone (a sync echoes back and walks the side put)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_synced_pan_by_fraction_for_different_sizes"
old = '''
if (mine) {
s.el.__synced = null;
if (Math.abs(s.el.scrollLeft - mine.l) < 1 && Math.abs(s.el.scrollTop - mine.t) < 1) return;
}'''
new = '''
'''
# ---- C2/C3: the view state and the keys, in the page
[[mutation]]
label = "C3 unlinked, an arrow still moves both sides"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
var which = (linked ? 'both' : active) + (dir < 0 ? '-prev' : '-next');'''
new = '''
var which = 'both' + (dir < 0 ? '-prev' : '-next');'''
[[mutation]]
label = "C2 the unlinked state does not survive a step (it is not in the next URL)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
if (!linked) parts.push('link=0');'''
new = '''
'''
[[mutation]]
label = "C2 X does not swap the active side"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'x' || k === 'X') setActive(active === 'a' ? 'b' : 'a');'''
new = '''
'''
[[mutation]]
label = "C2 the active stage does not wear the reticle"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
.cmp-side.is-active>.cmp-stagewrap::after,.film-f.is-active::after{content:"";'''
new = '''
.film-f.is-active::after{content:"";'''
[[mutation]]
label = "C2 a strip click always replaces B (not the side active now)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
var pick = f.getAttribute('data-pick-' + active);
if (!pick) return;'''
new = '''
var pick = f.getAttribute('data-pick-b');
if (!pick) return;'''
[[mutation]]
label = "C2 a press on a stage does not make its side active"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
s.el.addEventListener('pointerdown', function () { setActive(s.k); });'''
new = '''
'''
[[mutation]]
label = "C2 the active side is not written back into the URL (a reload forgets it)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_save_keeps_the_active_side"
old = '''
try { history.replaceState(history.state, '', withState(location.pathname + location.search)); } catch (e) {}'''
new = '''
'''
[[mutation]]
label = "C3 a held modifier does not make the keys inert"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (isEditable(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
var k = e.key;'''
new = '''
if (isEditable(e.target)) return;
var k = e.key;'''
[[mutation]]
label = "C3 Space on a focused control steps instead of pressing it"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
if (e.target.closest && e.target.closest('button, a, summary, video, audio')) return;'''
new = '''
'''
[[mutation]]
label = "INV-6 the review has no C key"
file = "booth/templates/view.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''
else if ((e.key === 'c' || e.key === 'C') && COMPARE) { e.preventDefault(); window.location.href = COMPARE; }'''
new = '''
'''
# ---- C5: judging in place
[[mutation]]
label = "C5 a stage is a region (a save swaps it, and a playing track restarts)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<div class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
new = '''<div data-region="stage-{{ key }}" class="vstage{% if s.kind == 'image' %} is-img{% endif %}'''
[[mutation]]
label = "C5 the B key presses the first flag button on the page (A's)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''
var btn = document.getElementById('cmp-flag-' + k.toLowerCase());'''
new = '''
var btn = document.querySelector('.cmp-flag .vflag-btn');'''
# ---- C6: blur
[[mutation]]
label = "C6 Reveal all leaves the per-side reveals standing"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
.reveal-all .cmp-reveal{display:none}'''
new = '''
'''
[[mutation]]
label = "C6 a side's reveal lifts A's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.a.querySelector('.vstage');'''
[[mutation]]
label = "C6 compare's root is not a .review (the blur rules do not reach its stages)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''<div class="viewer review compare" data-linked'''
new = '''<div class="viewer compare" data-linked'''
# ---- INV-4: without JS
[[mutation]]
label = "INV-4 the JS-only Linked toggle shows without JS (inline-flex beats [hidden])"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
.cmp-link[hidden]{display:none}'''
new = '''
'''
[[mutation]]
label = "INV-4 without JS a strip frame goes nowhere useful (links the review instead)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_without_js_every_judgment_and_step_still_works"
old = '''
href="{{ x.pick }}" data-rel'''
new = '''
href="/b/{{ name_url }}/view?f={{ x.url }}" data-rel'''
# ---- the top bar at phone width (the review's, and compare's)
[[mutation]]
label = "at phone width a full top bar scrolls the page sideways instead of wrapping"
file = "booth/templates/base.html"
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
[[mutation]]
label = "compare's top bar crushes its controls at phone width (the Fit | 1:1 toggle to 2px)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
new = '''
'''
# ---- the heid code-review fold (01M3AJXX8RH4QZPY5D1DQWQQGQ)
[[mutation]]
label = "C4 the separator is a border on B (B's stage 1px narrower than A's)"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
background:var(--surface-sunken)}'''
new = '''
background:var(--surface-sunken)}
.cmp-side+.cmp-side{border-left:1px solid var(--border-subtle)}'''
[[mutation]]
label = "C4 the stack break drifts to 1000px"
file = "booth/templates/base.html"
test = "tests/test_compare_browser.py::test_two_stages_side_by_side_wide_and_stacked_narrow"
old = '''
@media (max-width:900px){
.viewer.review.compare{display:block}'''
new = '''
@media (max-width:1000px){
.viewer.review.compare{display:block}'''
[[mutation]]
label = "C4 a side with nothing to scroll on an axis does not ignore it (0/0 resets the other)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_an_axis_with_nothing_to_scroll_is_ignored"
old = '''
if (fy > 0 && ty > 0) t = from.scrollTop / fy * ty;'''
new = '''
if (ty > 0) t = from.scrollTop / fy * ty;'''
[[mutation]]
label = "C4 two videos get a Fit | 1:1 toggle"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_video_or_track_plays_in_its_own_stage_and_two_get_no_toggle"
old = '''
"any_image": any(by_rel[r].kind == "image" for r in (a, b)),'''
new = '''
"any_image": True,'''
[[mutation]]
label = "C3 Space on a focused player steps the pair"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
old = '''e.target.closest('button, a, summary, video, audio')'''
new = '''e.target.closest('button, a, summary')'''
[[mutation]]
label = "C3 there is no back arrow"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_unlinked_moves_only_the_active_side_and_the_strip_picks_it"
old = '''
else if (k === 'ArrowLeft') step(-1);'''
new = '''
'''
[[mutation]]
label = "C1 containment is a bare prefix (a sibling booth sharing the name opens)"
file = "booth/app.py"
test = "tests/test_compare.py::test_an_outside_symlink_in_the_ring_is_404"
old = '''
return str(target).startswith(str(booth) + os.sep) and target.is_file()'''
new = '''
return str(target).startswith(str(booth)) and target.is_file()'''
[[mutation]]
label = "C1 the strip is not in ring order"
file = "booth/app.py"
test = "tests/test_compare.py::test_compare_renders_the_pair"
old = '''
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring]'''
new = '''
"pick_a": url(r, b), "pick_b": url(a, r)} for r in ring[::-1]]'''
[[mutation]]
label = "C6 each reveal lifts B's blur whichever side it sits on"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_blur_is_honest_on_both_sides"
old = '''
var stage = sides[btn.getAttribute('data-side')].querySelector('.vstage');'''
new = '''
var stage = sides.b.querySelector('.vstage');'''
[[mutation]]
label = "C5 the strip does not show a flag made in place"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_a_flags_A_in_place_and_the_stages_survive"
old = '''<a class="film-f{% if x.flagged %} is-flagged{% endif %}'''
new = '''<a class="film-f'''
# ---- the heid bug-hunt fold (01M3ANEPHTDMPP4Q18Z075181W)
[[mutation]]
label = "C1 navigation is built from the review ring (it offers an outside symlink that 404s)"
file = "booth/app.py"
test = "tests/test_compare.py::test_no_navigation_offers_a_pair_that_404s"
old = '''
return [r for r in review_chain(items) if _in_booth(booth, r)]'''
new = '''
return list(review_chain(items))'''
[[mutation]]
label = "a NUL in the booth segment is a 500 (ValueError is not an OSError)"
file = "booth/app.py"
test = "tests/test_compare.py::test_hostile_booth_names_are_404_not_500"
old = '''
resolved = candidate.resolve()
except (OSError, ValueError):'''
new = '''
resolved = candidate.resolve()
except OSError:'''
[[mutation]]
label = "a FIFO planted at .viewed hangs the look (a blocking open)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_planted_fifo_marker_cannot_hang_a_look"
old = '''os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o644)'''
new = '''os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644)'''
[[mutation]]
label = "C2 an encoded view-state name survives the rewrite (%73ide=a outlives X)"
file = "booth/templates/compare.html"
test = "tests/test_compare_browser.py::test_an_encoded_view_state_name_is_still_view_state"
old = '''
return p && n !== 'side' && n !== 'link';'''
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'''
[[mutation]]
label = "a NUL in the raw file path is a 500 (the stages load through this route)"
file = "booth/app.py"
test = "tests/test_compare.py::test_a_nul_in_a_file_path_is_404_not_500"
old = '''
target = (booth / filepath).resolve()
except (OSError, ValueError):'''
new = '''
target = (booth / filepath).resolve()
except OSError:'''
# 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,
# the pan continuing each time). "A non-UTF-8 name 500s compare in `quote()`" —
# booth_items never yields a rel that quote() cannot encode (test_flow's
# test_ordinals_count_rendered_items_only).
#
# Accepted, not rowed: the mode's `onChange: settleAll` (re-deciding which stage
# can pan) is redundant with compare's ResizeObserver — 1:1 drops the stage's
# padding, so every mode change resizes the stage's content box and the
# observer settles both. Its row stayed green; each alone holds.
#
# Accepted, not rowed: the `booth:swapped` restate is redundant with the URL
# rewrite — the in-place client re-fetches `location.href`, which already
# carries `side`, so the fresh strip renders the active side itself. Each alone
# holds; the save test (a_save_keeps_the_active_side) sees the pair.