# 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 = '''
''' new = '''
''' [[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 = '''
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 = '''