Files
booth/tests/mutations/r3.toml
T
vh f8d136a521 fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).

Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
2026-09-24 14:39:06 -07:00

562 lines
21 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 = '''
if rel not in ring:
raise HTTPException(status_code=404, detail="no such item")
return rel'''
new = '''
return 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)
a = _compare_side(booth, review_chain(items), a)'''
new = '''
booth = resolve_booth(name)
record_view(booth)
items = booth_items(booth)
a = _compare_side(booth, review_chain(items), a)'''
[[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 = ''' 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='/')}"),'''
# ---- 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') { 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);'''
# 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.