The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
first by the grid, the review and compare. A field or a player owns every
key but Escape (Esc still goes back from a focused player); a control
owns Space; a focused 1:1 stage that can pan owns the
arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
the browser's. The field check lives on as BoothKeys.isEditable. Before:
an arrow on a focused video left the review, and Enter on any control
also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
(script-set, one tile at a time) and focus, without a scroll; the cursor
is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
review only from the body, the grid or the tile, by its view?f= link;
n opens a closed doc's fold; Escape clears the cursor
and releases the tile's focus. The reticle is its focus mark (no second
ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
details.doc-fold (its summary is the label only) and div.doc-tools beside
it; the body and notes follow in div.doc-inline, hidden with a closed
fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
picture as the browser draws it (EXIF 5-8 swap), read from the header
only (no decode; PNG getexif is skipped unless the header carried it),
opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
included, so cp -p over a file is seen), in a separate
step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
its mark. content-visibility:auto, which the report proposed too, is NOT
added: a swapped-in tile has no remembered size, and a flag far down moved
the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
scroll-margin-top (the two add), so a control reached by Tab stops below
the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
rules' numbers (132px, 217px at <=480), now pinned by a test. The height
script follows the live rail after every in-place save (it watched the
replaced node, and read 0px after one flag), and the rail's own controls
cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
words on it, the glyph in an aria-hidden span, the item's name as
.sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
aria-pressed (its words already say the state; r2b rules them) and its
"on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
refusal's words, and a later save says every standing failure's words
(each once, in order) instead of "Saved.", and every warning says the
other standing failures first, so no failure buries another. Test first:
test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
(now in BoothKeys), r2c "the stage reveal shows with scripts off", and
this contract's S3 doc-bar row and five S5b status-line rows.
Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).
Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).
From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).
Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
597 lines
22 KiB
TOML
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"
|
|
# design-dev re-anchored this row and the two Space rows below in S5c: compare's
|
|
# guards moved into base.html's BoothKeys, which compare now calls first. Same
|
|
# failures, in the one place the rule now lives.
|
|
file = "booth/templates/base.html"
|
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
|
old = '''
|
|
if (e.metaKey || e.ctrlKey || e.altKey) return true;
|
|
var t = e.target, k = e.key;'''
|
|
new = '''
|
|
var t = e.target, k = e.key;'''
|
|
|
|
[[mutation]]
|
|
label = "C3 Space on a focused control steps instead of pressing it"
|
|
file = "booth/templates/base.html"
|
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
|
old = ''' if (k === ' ' && t.closest('button, a[href], summary')) return true;
|
|
'''
|
|
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/base.html"
|
|
test = "tests/test_compare_browser.py::test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view"
|
|
old = ''' if (BoothKeys.isEditable(t) || (k !== 'Escape' && t.closest('video, audio'))) return true;'''
|
|
new = ''' if (BoothKeys.isEditable(t)) return true;'''
|
|
|
|
[[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.
|