feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner
GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.
The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.
Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
This commit is contained in:
@@ -248,11 +248,23 @@ new = '''e.target !== player) {'''
|
||||
label = "the top-bar controls squeeze into multi-line stacks at phone width"
|
||||
file = "booth/templates/base.html"
|
||||
test = "tests/test_flow_browser.py::test_the_review_and_doc_top_bars_fit_a_phone"
|
||||
# r3 re-anchored this row. r3 added a Compare control to the review's bar and
|
||||
# made a full bar WRAP at phone width (a fogged booth already overflowed it by
|
||||
# 3px at 390). A wrapping bar never squeezes, so removing the no-wrap rules
|
||||
# alone went VACUOUS: the property is now held by both. The defeating change is
|
||||
# losing both; r3.toml rows the wrap on its own.
|
||||
old = '''
|
||||
.blur-all button,.reveal-all-btn{white-space:nowrap}
|
||||
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}'''
|
||||
@media (max-width:600px){.reveal-all-btn .ra-note{display:none}}
|
||||
/* R3: the review's Compare control keeps only its glyph at phone width; its
|
||||
title still says what it does (and C does it). */
|
||||
@media (max-width:600px){.vcompare-l{display:none}}
|
||||
/* ...and at phone width a top bar that cannot hold its controls WRAPS rather
|
||||
than scrolling the page sideways. The review's bar was full before R3 (a
|
||||
fogged booth overflowed it by 3px at 390); compare's bar holds more. */
|
||||
@media (max-width:600px){.vbar{flex-wrap:wrap;row-gap:6px}}'''
|
||||
new = '''
|
||||
'''
|
||||
@media (max-width:600px){.vcompare-l{display:none}}'''
|
||||
|
||||
[[mutation]]
|
||||
label = "the Desk strip under another booth's reveal is lifted by a whisker (blur(0px) is not blurred)"
|
||||
|
||||
+28
-18
@@ -1,5 +1,12 @@
|
||||
# R2c — the review stage: every falsifier the contract claims
|
||||
# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
|
||||
#
|
||||
# r3 moved the stage machinery (the mode toggle, the storage listener, pannable
|
||||
# and drag-pan) out of view.html into the shared _stage_js.html (r3 C4). The 15
|
||||
# rows that anchor there were RE-POINTED, not rewritten: same test, same
|
||||
# defeating change, in the code's new home, and every one re-proved after the
|
||||
# move. The arrows (place, drawn, view's ResizeObserver) stayed in view.html,
|
||||
# and so did their rows.
|
||||
|
||||
unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
|
||||
|
||||
@@ -23,12 +30,14 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S2 the toggle stays hidden (the per-picture hide is back)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
|
||||
# r3: BoothMode.bind is page level and holds no picture, so the re-pointed hide
|
||||
# reads the page's one picture and stage itself.
|
||||
old = '''
|
||||
toggle.hidden = false;'''
|
||||
new = '''
|
||||
toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
|
||||
toggle.hidden = document.querySelector('.vstage img').naturalWidth <= document.querySelector('.vstage').clientWidth;'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S2 the toggle shows without JS (display beats [hidden])"
|
||||
@@ -59,7 +68,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S2 a storage write that throws swallows the click"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||
old = '''
|
||||
var setMode = function (one) {
|
||||
@@ -112,7 +121,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S4 the pan runs backwards (the picture flees the pointer)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||
old = '''
|
||||
stage.scrollLeft = drag.l - dx;'''
|
||||
@@ -121,7 +130,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S4 no drag threshold (a jittery click pans)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||
old = '''
|
||||
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
|
||||
@@ -166,7 +175,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "a buttonless hover continues a press released outside the stage"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
|
||||
old = '''
|
||||
if (!(e.buttons & 1)) { endDrag(); return; }'''
|
||||
@@ -175,7 +184,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "no pointer capture (a pan dies at the stage's edge)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover"
|
||||
old = '''
|
||||
try { stage.setPointerCapture(drag.id); } catch (x) {}'''
|
||||
@@ -222,7 +231,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||
old = '''
|
||||
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
|
||||
@@ -252,7 +261,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "a stage mode chosen in another tab does not reach this one"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_a_stage_mode_chosen_in_one_tab_moves_the_others"
|
||||
old = '''
|
||||
if (e.key !== 'booth.fit' && e.key !== null) return;'''
|
||||
@@ -261,7 +270,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "the drag threshold drops to 3px"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
|
||||
old = '''
|
||||
if (dx * dx + dy * dy < 16) return; /* under 4px in all: a click */'''
|
||||
@@ -270,7 +279,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "a storage write that throws cuts the click short (buttons never update)"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||
old = '''
|
||||
try {
|
||||
@@ -281,7 +290,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "the Fit button's pressed state is never drawn"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||
old = '''
|
||||
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');'''
|
||||
@@ -302,16 +311,17 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S2 the toggle hides, on load, for a picture larger than the stage"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
|
||||
# r3: `settle` is the stage's own (pannable) then the page's onSettle (place).
|
||||
old = '''
|
||||
function settle() { place(); pannable(); }'''
|
||||
function settle() { pannable(); onSettle(); }'''
|
||||
new = '''
|
||||
function settle() { place(); pannable(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
|
||||
function settle() { pannable(); onSettle(); if (img) document.getElementById('vtoggle').hidden = img.naturalWidth > stage.clientWidth; }'''
|
||||
|
||||
[[mutation]]
|
||||
label = "S2 choosing Fit stores a word instead of forgetting 1:1"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
|
||||
old = '''
|
||||
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
|
||||
@@ -320,7 +330,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "S4 pan only when BOTH axes overflow"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_a_picture_that_overflows_one_axis_pans_along_it"
|
||||
old = '''
|
||||
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);'''
|
||||
@@ -331,7 +341,7 @@ new = '''
|
||||
|
||||
[[mutation]]
|
||||
label = "a press on the stage's scrollbar starts a pan"
|
||||
file = "booth/templates/view.html"
|
||||
file = "booth/templates/_stage_js.html"
|
||||
test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
|
||||
old = '''
|
||||
if (e.clientX - r.left - stage.clientLeft >= stage.clientWidth ||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
# 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 = '''
|
||||
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
|
||||
raise HTTPException(status_code=404, detail="no such item")
|
||||
if rel not in ring:'''
|
||||
new = '''
|
||||
if rel not in ring:'''
|
||||
|
||||
[[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)
|
||||
ring = review_chain(items)
|
||||
a = _compare_side(booth, ring, a)'''
|
||||
new = '''
|
||||
booth = resolve_booth(name)
|
||||
record_view(booth)
|
||||
items = booth_items(booth)
|
||||
ring = review_chain(items)
|
||||
a = _compare_side(booth, ring, 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(ring[(pos + 1) % len(ring)], safe='/')}"),'''
|
||||
new = ''' f"&b={quote(ring[min(pos + 1, len(ring) - 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;min-width:0;min-height:0}'''
|
||||
new = '''
|
||||
.cmp-side{display:grid;grid-row:1 / -1;grid-template-rows:auto minmax(0,1fr) auto;min-width:0;min-height:0}'''
|
||||
|
||||
[[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 = '''
|
||||
'''
|
||||
|
||||
# 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.
|
||||
@@ -0,0 +1,293 @@
|
||||
"""R3 — compare: two picked items of a booth side by side.
|
||||
|
||||
Contract: docs/contracts/r3_compare.contract.md. The server half: the route,
|
||||
the pair, the step and strip links, the regions, and the JS-off flag landing.
|
||||
The browser half is tests/test_compare_browser.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
|
||||
from booth.app import create_app # noqa: E402
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path:
|
||||
b = root / name
|
||||
b.mkdir()
|
||||
for rel, data in files.items():
|
||||
p = b / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
return b
|
||||
|
||||
|
||||
def _client(root: pathlib.Path) -> TestClient:
|
||||
return TestClient(create_app(root, ttl_hours=24, start_sweeper=False),
|
||||
follow_redirects=False)
|
||||
|
||||
|
||||
def _frames(body: str) -> dict[str, str]:
|
||||
"""rel -> the A/B marks its filmstrip frame carries ('' for none), in
|
||||
strip order."""
|
||||
film = re.search(r'<nav class="film"[^>]*data-region="film".*?</nav>', body, re.S).group(0)
|
||||
out = {}
|
||||
for f in re.findall(r'<a class="film-f[^"]*"[^>]*>.*?</a>', film, re.S):
|
||||
rel = re.search(r'data-rel="([^"]*)"', f).group(1)
|
||||
out[rel] = "".join(re.findall(r'<span class="film-ab">([AB]+)</span>', f))
|
||||
return out
|
||||
|
||||
|
||||
# ---- C1: the route and the pair ----------------------------------------------
|
||||
|
||||
def test_compare_renders_the_pair(tmp_path):
|
||||
"""The tracer: four pictures, #1 against #3. Both names and both ordinals
|
||||
are printed, and the filmstrip marks #1 A and #3 B."""
|
||||
_booth(tmp_path, "g", {f"{n}.png": PNG for n in ("p", "q", "r", "s")})
|
||||
r = _client(tmp_path).get("/b/g/compare?a=p.png&b=r.png")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
label_a = re.search(r'data-region="label-a".*?</div>', body, re.S).group(0)
|
||||
label_b = re.search(r'data-region="label-b".*?</div>', body, re.S).group(0)
|
||||
assert "p.png" in label_a and "#1" in label_a, label_a
|
||||
assert "r.png" in label_b and "#3" in label_b, label_b
|
||||
assert _frames(body) == {"p.png": "A", "q.png": "", "r.png": "B", "s.png": ""}
|
||||
|
||||
|
||||
def _four(root: pathlib.Path) -> pathlib.Path:
|
||||
"""Four pictures, a doc, a caption sidecar and a dotfile: every kind of
|
||||
thing a side can name that is not a side."""
|
||||
return _booth(root, "g", {"p.png": PNG, "q.png": PNG, "r.png": PNG, "s.png": PNG,
|
||||
"notes.md": b"# n", "p.png.txt": b"a caption",
|
||||
".hidden.png": PNG})
|
||||
|
||||
|
||||
def test_a_bad_side_is_a_404(tmp_path):
|
||||
"""Missing, traversal, a NUL, a dotfile, a doc item, a non-item file: a 404
|
||||
each, on either side, never a 500."""
|
||||
_four(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
bad = ["", "../g/p.png/..", "../../etc/passwd", "p.png\x00", ".hidden.png",
|
||||
"notes.md", "p.png.txt", "gone.png", "sub/"]
|
||||
for rel in bad:
|
||||
for q in ({"a": rel, "b": "q.png"}, {"a": "q.png", "b": rel}):
|
||||
r = c.get("/b/g/compare", params=q)
|
||||
assert r.status_code == 404, (q, r.status_code)
|
||||
for q in ({"b": "q.png"}, {"a": "q.png"}, {}):
|
||||
assert c.get("/b/g/compare", params=q).status_code == 404, q
|
||||
|
||||
|
||||
def test_a_missing_param_is_404_not_422(tmp_path):
|
||||
"""The review declares `f: str` and answers 422 without it; compare
|
||||
declares both sides with a default and answers 404."""
|
||||
_four(tmp_path)
|
||||
r = _client(tmp_path).get("/b/g/compare?a=p.png")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_an_outside_symlink_in_the_ring_is_404(tmp_path):
|
||||
"""`booth_items` follows symlinks, so a link pointing OUTSIDE the booth is
|
||||
in the review ring; only the containment check refuses it."""
|
||||
from booth.items import booth_items, review_chain
|
||||
b = _four(tmp_path)
|
||||
outside = tmp_path / "elsewhere.png"
|
||||
outside.write_bytes(PNG)
|
||||
(b / "zz-link.png").symlink_to(outside)
|
||||
assert "zz-link.png" in review_chain(booth_items(b)), "the fixture must put it in the ring"
|
||||
c = _client(tmp_path)
|
||||
assert c.get("/b/g/compare?a=zz-link.png&b=p.png").status_code == 404
|
||||
assert c.get("/b/g/compare?a=p.png&b=zz-link.png").status_code == 404
|
||||
|
||||
|
||||
def test_a_look_records_both_seen(tmp_path):
|
||||
"""A compare GET is a look at both sides; a 404 records nothing."""
|
||||
import json
|
||||
b = _four(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
assert c.get("/b/g/compare?a=p.png&b=gone.png").status_code == 404
|
||||
assert not (b / ".seen").exists() and not (b / ".viewed").exists()
|
||||
assert c.get("/b/g/compare?a=q.png&b=s.png").status_code == 200
|
||||
assert set(json.loads((b / ".seen").read_text())) == {"q.png", "s.png"}
|
||||
assert (b / ".viewed").exists()
|
||||
|
||||
|
||||
def test_compare_carries_data_booth(tmp_path):
|
||||
"""Reveal all's script and the head script's reveal restore both read
|
||||
`data-booth` off <html>, and bail without it."""
|
||||
_four(tmp_path)
|
||||
body = _client(tmp_path).get("/b/g/compare?a=p.png&b=q.png").text
|
||||
assert re.search(r'<html lang="en" data-booth="g">', body)
|
||||
|
||||
|
||||
def test_no_data_region_repeats(tmp_path):
|
||||
"""The swap keeps the FIRST fresh node per id and copies it over EVERY live
|
||||
node with that id, so a shared id would turn B's flag into A's. Unique,
|
||||
keyed by side — including when a == b."""
|
||||
_four(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
for q in ("a=p.png&b=r.png", "a=q.png&b=q.png"):
|
||||
# attributes only: base.html's script names `[data-region="status"]`
|
||||
ids = re.findall(r'\sdata-region="([^"]+)"', c.get(f"/b/g/compare?{q}").text)
|
||||
assert len(ids) == len(set(ids)), (q, ids)
|
||||
assert {"flag-a", "flag-b", "label-a", "label-b", "film"} <= set(ids), ids
|
||||
assert not [i for i in ids if i.startswith("item-")], ids
|
||||
body = c.get("/b/g/compare?a=q.png&b=q.png").text
|
||||
assert _frames(body)["q.png"] == "AB", "a == b marks the one frame both ways"
|
||||
|
||||
|
||||
# ---- C3: stepping --------------------------------------------------------------
|
||||
|
||||
def _ring6(root: pathlib.Path) -> pathlib.Path:
|
||||
"""Six media with a DOC between them: 03-notes.md takes ordinal 3, so an
|
||||
ordinal is not a ring position."""
|
||||
return _booth(root, "g", {"01.png": PNG, "02.png": PNG, "03-notes.md": b"# n",
|
||||
"04.png": PNG, "05.png": PNG, "06.png": PNG, "07.png": PNG})
|
||||
|
||||
|
||||
def _step(body: str, which: str) -> tuple[str, str]:
|
||||
"""The (a, b) rels a step link targets."""
|
||||
href = re.search(r'<a [^>]*data-step="%s"[^>]*href="([^"]+)"' % which, body)
|
||||
href = href or re.search(r'<a [^>]*href="([^"]+)"[^>]*data-step="%s"' % which, body)
|
||||
q = parse_qs(urlsplit(href.group(1).replace("&", "&")).query)
|
||||
return q["a"][0], q["b"][0]
|
||||
|
||||
|
||||
def test_linked_steps_keep_the_distance_and_wrap(tmp_path):
|
||||
"""Ring positions 2 and 5 step forward to (3, 6), then (4, 1) — wrapped —
|
||||
and back from (1, 4) to (6, 3). Named by rel, never by ordinal."""
|
||||
_ring6(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
body = c.get("/b/g/compare?a=02.png&b=06.png").text
|
||||
assert _step(body, "both-next") == ("04.png", "07.png")
|
||||
body = c.get("/b/g/compare?a=04.png&b=07.png").text
|
||||
assert _step(body, "both-next") == ("05.png", "01.png")
|
||||
body = c.get("/b/g/compare?a=01.png&b=05.png").text
|
||||
assert _step(body, "both-prev") == ("07.png", "04.png")
|
||||
# each side on its own moves only itself, and wraps the same way
|
||||
assert _step(body, "a-prev") == ("07.png", "05.png")
|
||||
assert _step(body, "a-next") == ("02.png", "05.png")
|
||||
assert _step(body, "b-prev") == ("01.png", "04.png")
|
||||
assert _step(body, "b-next") == ("01.png", "06.png")
|
||||
|
||||
|
||||
def _compare_links(body: str) -> list[str]:
|
||||
"""Every step and filmstrip href on the page, entity-decoded."""
|
||||
hrefs = re.findall(r'<a [^>]*(?:data-step="[^"]+"[^>]*href|class="film-f[^"]*"[^>]*href)="([^"]+)"', body)
|
||||
return [h.replace("&", "&") for h in hrefs]
|
||||
|
||||
|
||||
def test_the_urls_are_keyed_by_rel(tmp_path):
|
||||
"""Every step and strip link names both sides by rel, url-quoted; none
|
||||
carries an ordinal or any key beyond the pair and the view state."""
|
||||
_booth(tmp_path, "g", {"a b.png": PNG, "sub dir/c#d.png": PNG, "e&f.png": PNG})
|
||||
body = _client(tmp_path).get("/b/g/compare", params={"a": "a b.png", "b": "sub dir/c#d.png"}).text
|
||||
links = _compare_links(body)
|
||||
assert len(links) == 6 + 3, links # six steps, three frames
|
||||
for h in links:
|
||||
u = urlsplit(h)
|
||||
assert u.path == "/b/g/compare", h
|
||||
assert " " not in h and "#" not in u.query.replace("%23", ""), h
|
||||
q = parse_qs(u.query)
|
||||
assert set(q) == {"a", "b"}, h
|
||||
assert all(v in ("a b.png", "sub dir/c#d.png", "e&f.png") for v in (q["a"][0], q["b"][0])), h
|
||||
|
||||
|
||||
def test_view_state_rides_the_links(tmp_path):
|
||||
"""`side=a&link=0` rides every step and strip link; an unknown value reads
|
||||
as the default (B active, linked) and is never an error."""
|
||||
_four(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
body = c.get("/b/g/compare?a=p.png&b=r.png&side=a&link=0").text
|
||||
links = _compare_links(body)
|
||||
assert links and all(h.endswith("&side=a&link=0") for h in links), links
|
||||
assert 'class="cmp-side is-active" data-side="a"' in body
|
||||
# with A active a frame replaces A, keeping B
|
||||
frame = re.search(r'<a class="film-f[^"]*"\s+href="([^"]+)" data-rel="q.png"', body).group(1)
|
||||
assert parse_qs(urlsplit(frame.replace("&", "&")).query) == {
|
||||
"a": ["q.png"], "b": ["r.png"], "side": ["a"], "link": ["0"]}
|
||||
for odd in ("side=z&link=maybe", "side=A&link=00", "side=&link="):
|
||||
r = c.get(f"/b/g/compare?a=p.png&b=r.png&{odd}")
|
||||
assert r.status_code == 200, odd
|
||||
links = _compare_links(r.text)
|
||||
assert all(set(parse_qs(urlsplit(h).query)) == {"a", "b"} for h in links), (odd, links)
|
||||
assert 'class="cmp-side is-active" data-side="b"' in r.text, odd
|
||||
assert 'data-linked="1"' in r.text, odd
|
||||
|
||||
|
||||
# ---- C5: judging without JS -----------------------------------------------------
|
||||
|
||||
def _flag(c: TestClient, form: dict, accept: str | None = None):
|
||||
headers = {"accept": accept} if accept else {}
|
||||
return c.post("/b/g/flag", data={"target": "q.png", "on": "1", **form}, headers=headers)
|
||||
|
||||
|
||||
def test_a_flag_without_js_lands_on_the_same_pair(tmp_path):
|
||||
"""`back=compare` lands on exactly the pair, built from the checked rels,
|
||||
with the view state mapped from a closed set and never echoed, and no
|
||||
fragment. Anything outside the ring takes the no-`back` landing; the
|
||||
in-place answer is the 204 it always was."""
|
||||
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG, "sub dir/r s.png": PNG, "n.md": b"# n"})
|
||||
c = _client(tmp_path)
|
||||
pair = {"back": "compare", "a": "p.png", "b": "sub dir/r s.png"}
|
||||
want = "/b/g/compare?a=p.png&b=sub%20dir/r%20s.png"
|
||||
r = _flag(c, pair)
|
||||
assert r.status_code == 303 and r.headers["location"] == want
|
||||
assert _flag(c, {**pair, "side": "a", "link": "0"}).headers["location"] == want + "&side=a&link=0"
|
||||
assert _flag(c, {**pair, "link": "0"}).headers["location"] == want + "&link=0"
|
||||
for odd in ({"side": "A"}, {"link": "00"}, {"side": "b", "link": "1"},
|
||||
{"side": "a#x", "link": "0&side=a"}):
|
||||
assert _flag(c, {**pair, **odd}).headers["location"] == want, odd
|
||||
no_back = _flag(c, {"target": "q.png"}).headers["location"]
|
||||
assert no_back == "/b/g/#item-q.png"
|
||||
for bad in ({"a": "n.md"}, {"b": "gone.png"}, {"a": "../g/p.png"}, {"b": ""}):
|
||||
assert _flag(c, {**pair, **bad}).headers["location"] == no_back, bad
|
||||
r = _flag(c, pair, accept="application/json")
|
||||
assert r.status_code == 204 and "location" not in r.headers
|
||||
|
||||
|
||||
def test_every_other_landing_is_byte_identical(tmp_path):
|
||||
"""R2 INV-4: `back=view`, `back=marks` and no `back` land exactly where they
|
||||
did before compare existed."""
|
||||
_booth(tmp_path, "g", {"p.png": PNG, "q.png": PNG})
|
||||
c = _client(tmp_path)
|
||||
assert _flag(c, {}).headers["location"] == "/b/g/#item-q.png"
|
||||
assert _flag(c, {"back": "marks"}).headers["location"] == "/b/g/marks#item-q.png"
|
||||
assert _flag(c, {"back": "view", "f": "p.png"}).headers["location"] == "/b/g/view?f=p.png#rail"
|
||||
assert _flag(c, {"back": "view", "f": "gone.png"}).headers["location"] == "/b/g/#item-q.png"
|
||||
assert _flag(c, {"back": "Compare", "a": "p.png", "b": "q.png"}).headers["location"] == "/b/g/#item-q.png"
|
||||
|
||||
|
||||
# ---- C2: picking from the review ------------------------------------------------
|
||||
|
||||
def _compare_href(body: str) -> tuple[str, str]:
|
||||
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
|
||||
u = urlsplit(href.replace("&", "&"))
|
||||
assert u.path == "/b/g/compare", href
|
||||
q = parse_qs(u.query)
|
||||
assert set(q) == {"a", "b"}, href
|
||||
return q["a"][0], q["b"][0]
|
||||
|
||||
|
||||
def test_the_review_offers_compare_with_the_next_item(tmp_path):
|
||||
"""The review's Compare control opens this item against the NEXT media item
|
||||
in the ring — skipping the doc — and the last wraps to the first. A ring of
|
||||
one compares the item with itself."""
|
||||
_ring6(tmp_path)
|
||||
c = _client(tmp_path)
|
||||
assert _compare_href(c.get("/b/g/view?f=02.png").text) == ("02.png", "04.png")
|
||||
assert _compare_href(c.get("/b/g/view?f=07.png").text) == ("07.png", "01.png")
|
||||
_booth(tmp_path, "one", {"only.png": PNG, "n.md": b"# n"})
|
||||
body = c.get("/b/one/view?f=only.png").text
|
||||
href = re.search(r'<a class="vbtn vcompare"[^>]*href="([^"]+)"', body).group(1)
|
||||
assert href.replace("&", "&") == "/b/one/compare?a=only.png&b=only.png"
|
||||
# a doc's own page is not a review, and offers no compare
|
||||
assert 'class="vbtn vcompare"' not in c.get("/b/g/view?f=03-notes.md").text
|
||||
@@ -0,0 +1,451 @@
|
||||
"""R3 — compare, in a real DOM.
|
||||
|
||||
Contract: docs/contracts/r3_compare.contract.md. A TestClient can prove what
|
||||
the server answers; it cannot prove that two stages sit side by side, that a
|
||||
pan on one lands the other on the same crop, or that an in-place save keeps
|
||||
the active side. The harness is test_flow_browser's (a real uvicorn, a real
|
||||
offline Chromium), and like it this SKIPS, never fails, without a browser.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
|
||||
from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures)
|
||||
|
||||
|
||||
def _pics(root: pathlib.Path, pics: dict, name: str = "g") -> pathlib.Path:
|
||||
b = root / name
|
||||
b.mkdir()
|
||||
for rel, (w, h) in pics.items():
|
||||
p = b / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(_png(w, h))
|
||||
return b
|
||||
|
||||
|
||||
def _open(page, url):
|
||||
"""Load a compare page and wait until every picture on a stage decoded."""
|
||||
page.goto(url, wait_until="networkidle")
|
||||
page.wait_for_function("""[...document.querySelectorAll('.cmp-side .vstage img')]
|
||||
.every(i => i.complete && i.naturalWidth > 0)""")
|
||||
page.wait_for_timeout(150)
|
||||
|
||||
|
||||
_BOXES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => {
|
||||
const b = s.getBoundingClientRect();
|
||||
return {side: s.dataset.side, l: b.left, r: b.right, t: b.top, b: b.bottom, h: b.height};
|
||||
})"""
|
||||
|
||||
|
||||
def test_two_stages_side_by_side_wide_and_stacked_narrow(browser, live):
|
||||
"""The tracer. At 1440 A and B share one row, A on the left, each about
|
||||
half the body. At 390 they stack, A above B, each at most 45vh tall."""
|
||||
base, root = live
|
||||
from booth.app import set_blurred
|
||||
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
|
||||
(b / "a.png.txt").write_text("a caption on A only, " * 8)
|
||||
set_blurred(b, "b.png", True) # the bar carries Reveal all too: its fullest
|
||||
got = {}
|
||||
for w, h in ((1440, 900), (390, 844)):
|
||||
page = browser.new_page(viewport={"width": w, "height": h})
|
||||
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
got[w] = (page.evaluate(_BOXES), h,
|
||||
page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth"),
|
||||
# and nothing squeezed: every top-bar control one line, none
|
||||
# crushed narrower than its own content (a flex item shrinks
|
||||
# before it overflows — the Fit | 1:1 toggle went to 2px)
|
||||
page.evaluate("""() => { const c = [...document.querySelectorAll(
|
||||
'.vbar button, .vbar .vbtn, .vbar .vtoggle')].filter(e => e.offsetParent);
|
||||
return [Math.max(...c.map(e => e.getBoundingClientRect().height)),
|
||||
Math.max(...c.map(e => e.scrollWidth - e.clientWidth))]; }"""))
|
||||
page.close()
|
||||
(a, b), _, over, tallest = got[1440]
|
||||
assert (a["side"], b["side"]) == ("a", "b")
|
||||
assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], (a, b)
|
||||
assert a["r"] - a["l"] > 1440 * 0.4 and b["r"] - b["l"] > 1440 * 0.4, (a, b)
|
||||
# the SAME stage for both, or Fit draws one smaller: A's caption must not
|
||||
# take its height from A's stage alone
|
||||
assert abs(a["h"] - b["h"]) <= 1 and abs(a["b"] - b["b"]) <= 1, (a, b)
|
||||
assert over <= 0 and tallest[0] <= 40 and tallest[1] <= 1, (over, tallest)
|
||||
(a, b), vh, over, tallest = got[390]
|
||||
assert a["b"] <= b["t"], ("A above B", a, b)
|
||||
assert a["h"] <= 0.45 * vh + 1 and b["h"] <= 0.45 * vh + 1, (a, b)
|
||||
assert a["h"] > 100 and b["h"] > 100, (a, b)
|
||||
assert over <= 0, "the compare page scrolls sideways at phone width"
|
||||
assert tallest[0] <= 40, ("a top-bar control squeezed into a stack", tallest)
|
||||
assert tallest[1] <= 1, ("a top-bar control crushed narrower than its content", tallest)
|
||||
|
||||
|
||||
_IMGS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => {
|
||||
const b = i.getBoundingClientRect();
|
||||
return [Math.round(b.width), Math.round(b.height), i.naturalWidth, i.naturalHeight];
|
||||
})"""
|
||||
|
||||
|
||||
def test_one_mode_for_both_and_for_the_review(browser, live):
|
||||
"""`Z` switches BOTH stages to 1:1 and stores it as the review's own
|
||||
preference: the review then opens in 1:1, and Z back is Fit for both."""
|
||||
base, root = live
|
||||
_pics(root, {"a.png": (1600, 1200), "b.png": (1400, 1000)})
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = ctx.new_page()
|
||||
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
fit = page.evaluate(_IMGS)
|
||||
page.keyboard.press("z")
|
||||
page.wait_for_timeout(150)
|
||||
one = page.evaluate(_IMGS)
|
||||
# both larger than their stages, so both offer the grab at once — not at
|
||||
# the next resize
|
||||
grab = page.evaluate("[...document.querySelectorAll('.cmp-side .vstage')].map(s => getComputedStyle(s).cursor)")
|
||||
stored = page.evaluate("localStorage.getItem('booth.fit')")
|
||||
pressed = page.locator("#btn-one").get_attribute("aria-pressed")
|
||||
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
|
||||
review_one = page.evaluate("document.documentElement.classList.contains('stage-one')")
|
||||
page.goto(f"{base}/b/g/compare?a=a.png&b=b.png", wait_until="networkidle")
|
||||
page.keyboard.press("Z")
|
||||
back = page.evaluate("[document.documentElement.classList.contains('stage-one'), localStorage.getItem('booth.fit')]")
|
||||
ctx.close()
|
||||
assert all([w, h] != [nw, nh] for w, h, nw, nh in fit), fit
|
||||
assert all([w, h] == [nw, nh] for w, h, nw, nh in one), one
|
||||
assert grab == ["grab", "grab"], grab
|
||||
assert stored == "one" and pressed == "true" and review_one is True, (stored, pressed, review_one)
|
||||
assert back == [False, None], back
|
||||
|
||||
|
||||
_SCROLLS = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => [s.scrollLeft, s.scrollTop])"""
|
||||
|
||||
|
||||
def _one_to_one(page, url):
|
||||
"""Open a compare in 1:1 (the stored preference, applied before paint)."""
|
||||
page.add_init_script("try { localStorage.setItem('booth.fit', 'one'); } catch (e) {}")
|
||||
_open(page, url)
|
||||
|
||||
|
||||
def _center(page, side):
|
||||
box = page.locator(f'.cmp-side[data-side="{side}"] .vstage').bounding_box()
|
||||
return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
|
||||
|
||||
|
||||
def test_synced_pan_lands_on_the_same_crop(browser, live):
|
||||
"""Two pictures of one size, larger than the stage, in 1:1. A drag on A of
|
||||
(+80, +60) scrolls BOTH by (-80, -60): the same pixels under the same
|
||||
point. A wheel on B moves A with it. After a second of idle neither has
|
||||
moved on its own (no sync loop)."""
|
||||
base, root = live
|
||||
_pics(root, {"a.png": (3000, 3000), "b.png": (3000, 3000)})
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(500, 500)")
|
||||
page.wait_for_timeout(200)
|
||||
start = page.evaluate(_SCROLLS)
|
||||
cx, cy = _center(page, "a")
|
||||
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
|
||||
page.wait_for_timeout(200)
|
||||
dragged = page.evaluate(_SCROLLS)
|
||||
bx, by = _center(page, "b")
|
||||
page.mouse.move(bx, by)
|
||||
page.mouse.wheel(0, 300)
|
||||
page.wait_for_timeout(600)
|
||||
wheeled = page.evaluate(_SCROLLS)
|
||||
page.wait_for_timeout(1000)
|
||||
idle = page.evaluate(_SCROLLS)
|
||||
page.close()
|
||||
assert start == [[500, 500], [500, 500]], start
|
||||
assert dragged == [[420, 440], [420, 440]], dragged
|
||||
assert wheeled[1][1] > 440 and wheeled[0] == wheeled[1], wheeled
|
||||
assert idle == wheeled, (wheeled, idle)
|
||||
|
||||
|
||||
_RANGES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s =>
|
||||
[s.scrollWidth - s.clientWidth, s.scrollHeight - s.clientHeight])"""
|
||||
|
||||
# The first y near `from` on B whose trip B -> A -> B does not come back to
|
||||
# itself when each scroll lands on a whole pixel: where a sync that re-synced
|
||||
# its own echo would walk B off the spot it was put on.
|
||||
_LOSSY = """([from, rA, rB]) => {
|
||||
for (let y = from; y < from + 200; y++)
|
||||
if (Math.round(Math.round(y / rB * rA) / rA * rB) !== y) return y;
|
||||
return from;
|
||||
}"""
|
||||
|
||||
|
||||
def test_synced_pan_by_fraction_for_different_sizes(browser, live):
|
||||
"""A 2000px and a 3000px picture. One at its middle puts the other at ITS
|
||||
middle; one at 25% of its range puts the other at 25% of ITS range — not
|
||||
at the same pixel offset. The equal-size test cannot see a fraction bug
|
||||
(equal overflow makes offsets and fractions coincide); this one can. And a
|
||||
side put somewhere STAYS there: the sync never echoes back and walks it."""
|
||||
base, root = live
|
||||
_pics(root, {"a.png": (2000, 2000), "b.png": (3000, 3000)})
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
(rax, ray), (rbx, rby) = page.evaluate(_RANGES)
|
||||
a = '.cmp-side[data-side="a"] .vstage'
|
||||
b = '.cmp-side[data-side="b"] .vstage'
|
||||
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [a, round(rax / 2), round(ray / 2)])
|
||||
page.wait_for_timeout(250)
|
||||
middle = page.evaluate(_SCROLLS)
|
||||
y = page.evaluate(_LOSSY, [round(rby / 4), ray, rby])
|
||||
x = page.evaluate(_LOSSY, [round(rbx / 4), rax, rbx])
|
||||
page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [b, x, y])
|
||||
page.wait_for_timeout(250)
|
||||
quarter = page.evaluate(_SCROLLS)
|
||||
page.wait_for_timeout(1000)
|
||||
idle = page.evaluate(_SCROLLS)
|
||||
page.close()
|
||||
assert rbx > rax > 0 and rby > ray > 0, (rax, ray, rbx, rby)
|
||||
assert abs(middle[1][0] - rbx / 2) <= 1 and abs(middle[1][1] - rby / 2) <= 1, (middle, rbx, rby)
|
||||
assert abs(quarter[0][0] / rax - x / rbx) * rax <= 1, (quarter, x)
|
||||
assert abs(quarter[0][1] / ray - y / rby) * ray <= 1, (quarter, y)
|
||||
assert quarter[0][0] < x - 100, "A must sit at ITS 25%, not at B's pixel offset"
|
||||
assert quarter[1] == [x, y] and idle == quarter, (x, y, quarter, idle)
|
||||
|
||||
|
||||
def test_a_flags_A_in_place_and_the_stages_survive(browser, live):
|
||||
"""`A` flags A in place: no navigation, A's control and label show the
|
||||
flag, B's do not, and both stages are the SAME nodes — a save swaps the
|
||||
regions and never a stage (a playing track would restart)."""
|
||||
from booth.marks import marks_for
|
||||
base, root = live
|
||||
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
page.evaluate("""() => { window.__noReload = 1;
|
||||
window.__stages = [...document.querySelectorAll('.cmp-side .vstage')]; }""")
|
||||
page.keyboard.press("a")
|
||||
page.wait_for_selector("#cmp-flag-a.is-flagged", timeout=10000)
|
||||
got = page.evaluate("""() => ({
|
||||
reload: window.__noReload !== 1,
|
||||
same: [...document.querySelectorAll('.cmp-side .vstage')].every((s, i) => s === window.__stages[i]),
|
||||
a: document.querySelector('[data-region="label-a"]').textContent,
|
||||
b: document.querySelector('[data-region="label-b"]').textContent,
|
||||
bflag: document.getElementById('cmp-flag-b').classList.contains('is-flagged'),
|
||||
})""")
|
||||
flagged = [m.target for m in marks_for(b) if m.shape == "flag"]
|
||||
# the save REPLACED the buttons: each key must find the fresh one
|
||||
page.keyboard.press("b")
|
||||
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
|
||||
page.keyboard.press("a")
|
||||
page.wait_for_selector("#cmp-flag-a:not(.is-flagged)", timeout=10000)
|
||||
after = sorted(m.target for m in marks_for(b) if m.shape == "flag")
|
||||
reloaded = page.evaluate("window.__noReload !== 1")
|
||||
page.close()
|
||||
assert not got["reload"] and got["same"], got
|
||||
assert "flagged" in got["a"] and "flagged" not in got["b"] and not got["bflag"], got
|
||||
assert flagged == ["a.png"] and after == ["b.png"] and not reloaded, (flagged, after, reloaded)
|
||||
|
||||
|
||||
def _bakeoff(root: pathlib.Path) -> pathlib.Path:
|
||||
"""sindra-bakeoff's shape: two lanes, m and r, the same scenes and seeds,
|
||||
laid out as two parallel runs in sorted order — no pairing rule needed."""
|
||||
scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42")
|
||||
return _pics(root, {f"{lane}-{i}-{sc}.png": (400, 300)
|
||||
for lane in ("m", "r") for i, sc in enumerate(scenes, 1)})
|
||||
|
||||
|
||||
def _pair(page) -> tuple[str, str]:
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
q = parse_qs(urlsplit(page.url).query)
|
||||
return q["a"][0], q["b"][0]
|
||||
|
||||
|
||||
def _press_and_wait(page, key):
|
||||
with page.expect_navigation(wait_until="networkidle"):
|
||||
page.keyboard.press(key)
|
||||
|
||||
|
||||
def test_linked_arrow_walks_a_bakeoff(browser, live):
|
||||
"""m#1 against r#1, then `→` three times: every pair is the same scene and
|
||||
seed in the two lanes."""
|
||||
base, root = live
|
||||
_bakeoff(root)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
|
||||
pairs = [_pair(page)]
|
||||
for _ in range(3):
|
||||
_press_and_wait(page, "ArrowRight")
|
||||
pairs.append(_pair(page))
|
||||
page.close()
|
||||
assert [(a[2:], b[2:]) for a, b in pairs] == [(a[2:], a[2:]) for a, _ in pairs], pairs
|
||||
assert [a[0] + b[0] for a, b in pairs] == ["mr"] * 4, pairs
|
||||
assert pairs[-1] == ("m-4-tower-s42.png", "r-4-tower-s42.png"), pairs
|
||||
|
||||
|
||||
def test_unlinked_moves_only_the_active_side_and_the_strip_picks_it(browser, live):
|
||||
"""`L` unlinks: `→` moves only B, and a SECOND `→` still moves only B (the
|
||||
state survived the navigation). A strip click replaces the active side.
|
||||
`X` swaps the active side, the reticle follows, and it survives a step."""
|
||||
base, root = live
|
||||
_bakeoff(root)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
|
||||
page.keyboard.press("l")
|
||||
linked = page.locator("#cmp-link").get_attribute("aria-pressed")
|
||||
_press_and_wait(page, "ArrowRight")
|
||||
one = _pair(page)
|
||||
_press_and_wait(page, "ArrowRight")
|
||||
two = _pair(page)
|
||||
with page.expect_navigation(wait_until="networkidle"):
|
||||
page.locator('.film-f[data-rel="m-3-marsh-s37.png"]').click()
|
||||
picked = _pair(page)
|
||||
page.keyboard.press("x")
|
||||
active = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
|
||||
[...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel)]""")
|
||||
_press_and_wait(page, "ArrowRight")
|
||||
stepped = _pair(page)
|
||||
after = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side,
|
||||
getComputedStyle(document.querySelector('.cmp-side.is-active > .cmp-stagewrap'), '::after').backgroundImage !== 'none',
|
||||
getComputedStyle(document.querySelector('.cmp-side:not(.is-active) > .cmp-stagewrap'), '::after').backgroundImage !== 'none']""")
|
||||
page.close()
|
||||
assert linked == "false", linked
|
||||
assert one == ("m-1-dock-s11.png", "r-2-forge-s23.png"), one
|
||||
assert two == ("m-1-dock-s11.png", "r-3-marsh-s37.png"), two
|
||||
assert picked == ("m-1-dock-s11.png", "m-3-marsh-s37.png"), picked
|
||||
assert active == ["a", ["m-1-dock-s11.png"]], active
|
||||
assert stepped == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), stepped
|
||||
assert after == ["a", True, False], after
|
||||
|
||||
|
||||
_FILTERS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => getComputedStyle(i).filter)"""
|
||||
_REVEALS = """() => [...document.querySelectorAll('.cmp-reveal')].map(b => getComputedStyle(b).display !== 'none')"""
|
||||
|
||||
|
||||
def test_blur_is_honest_on_both_sides(browser, live):
|
||||
"""A blurred side IS blurred — the computed filter, not just a class. Its
|
||||
own reveal lifts it and leaves the other side blurred. Reveal all lifts
|
||||
both, and stands both per-side reveals down."""
|
||||
from booth.app import set_blurred
|
||||
base, root = live
|
||||
b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)})
|
||||
set_blurred(b, "a.png", True)
|
||||
set_blurred(b, "b.png", True)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=a.png&b=b.png")
|
||||
before = page.evaluate(_FILTERS)
|
||||
shown = page.evaluate(_REVEALS)
|
||||
page.locator('.cmp-reveal[data-side="b"]').click()
|
||||
page.wait_for_timeout(400) # the filter transition
|
||||
own = page.evaluate(_FILTERS)
|
||||
page.locator('.cmp-reveal[data-side="b"]').click()
|
||||
page.locator("[data-reveal-all]").click()
|
||||
page.wait_for_timeout(400)
|
||||
everything = page.evaluate(_FILTERS)
|
||||
stood_down = page.evaluate(_REVEALS)
|
||||
page.close()
|
||||
assert all("blur(" in f for f in before), before
|
||||
assert shown == [True, True], shown
|
||||
assert "blur(" in own[0] and "blur(" not in own[1], own
|
||||
assert all("blur(" not in f for f in everything), everything
|
||||
assert stood_down == [False, False], stood_down
|
||||
|
||||
|
||||
def test_a_save_keeps_the_active_side(browser, live):
|
||||
"""Make A active (a press on its stage), then flag B in place. The save
|
||||
swaps the strip and the labels, and A is STILL the active side — on its
|
||||
stage and on the strip — and a strip click after the swap replaces A."""
|
||||
base, root = live
|
||||
_bakeoff(root)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png")
|
||||
ax, ay = _center(page, "a")
|
||||
page.mouse.click(ax, ay)
|
||||
page.evaluate("window.__noReload = 1")
|
||||
page.keyboard.press("b")
|
||||
page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000)
|
||||
kept = page.evaluate("""() => ({
|
||||
reload: window.__noReload !== 1,
|
||||
side: document.querySelector('.cmp-side.is-active').dataset.side,
|
||||
strip: [...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel),
|
||||
url: location.search,
|
||||
})""")
|
||||
with page.expect_navigation(wait_until="networkidle"):
|
||||
page.locator('.film-f[data-rel="m-2-forge-s23.png"]').click()
|
||||
picked = _pair(page)
|
||||
side = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
|
||||
# the active side lives in the URL: `X` rewrites it in place, so a reload
|
||||
# shows the side that was active, not the default
|
||||
page.keyboard.press("x")
|
||||
page.reload(wait_until="networkidle")
|
||||
reloaded = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side")
|
||||
page.close()
|
||||
assert reloaded == "b", reloaded
|
||||
assert not kept["reload"] and kept["side"] == "a", kept
|
||||
assert kept["strip"] == ["m-1-dock-s11.png"] and "side=a" in kept["url"], kept
|
||||
assert picked == ("m-2-forge-s23.png", "r-1-dock-s11.png") and side == "a", (picked, side)
|
||||
|
||||
|
||||
def test_without_js_every_judgment_and_step_still_works(browser, live):
|
||||
"""INV-4. Scripts off: the pair renders in two Fit stages, the step and
|
||||
strip links navigate, both flag forms are there and a flag lands back on
|
||||
the same pair. Nothing judgment-bearing hides behind a script."""
|
||||
from booth.marks import marks_for
|
||||
base, root = live
|
||||
b = _bakeoff(root)
|
||||
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1440, "height": 900})
|
||||
page = ctx.new_page()
|
||||
page.goto(f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png", wait_until="networkidle")
|
||||
fit = page.evaluate("document.documentElement.classList.contains('stage-one')")
|
||||
hidden = [page.locator(s).is_visible() for s in ("#cmp-link", "#vtoggle")]
|
||||
forms = page.locator(".cmp-flag form").count()
|
||||
page.locator('a[data-step="both-next"]').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
both = _pair(page)
|
||||
page.locator('a[data-step="a-prev"]').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
a_back = _pair(page)
|
||||
page.locator('.film-f[data-rel="m-4-tower-s42.png"]').click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
strip = _pair(page)
|
||||
page.locator("#cmp-flag-b").click()
|
||||
page.wait_for_load_state("networkidle")
|
||||
landed = _pair(page)
|
||||
shows = page.locator("#cmp-flag-b").get_attribute("class")
|
||||
ctx.close()
|
||||
assert fit is False and hidden == [False, False] and forms == 2, (fit, hidden, forms)
|
||||
assert both == ("m-2-forge-s23.png", "r-2-forge-s23.png"), both
|
||||
assert a_back == ("m-1-dock-s11.png", "r-2-forge-s23.png"), a_back
|
||||
assert strip == ("m-1-dock-s11.png", "m-4-tower-s42.png"), strip
|
||||
assert landed == strip and "is-flagged" in shows, (landed, shows)
|
||||
assert [m.target for m in marks_for(b) if m.shape == "flag"] == ["m-4-tower-s42.png"]
|
||||
|
||||
|
||||
def test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view(browser, live):
|
||||
"""C3: a held modifier makes every key inert; Space on a focused control
|
||||
presses it and never steps, and from nowhere in particular it steps
|
||||
(Shift+Space back). `C` in the review opens compare against the next
|
||||
item; `Esc` (or `C`) in compare returns to the review of A."""
|
||||
base, root = live
|
||||
_bakeoff(root)
|
||||
page = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||
_open(page, f"{base}/b/g/compare?a=m-2-forge-s23.png&b=r-2-forge-s23.png")
|
||||
start = page.url
|
||||
for key in ("Control+ArrowRight", "Alt+ArrowRight", "Meta+ArrowRight", "Control+x", "Alt+l"):
|
||||
page.keyboard.press(key)
|
||||
page.wait_for_timeout(300)
|
||||
inert = (page.url == start,
|
||||
page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side"),
|
||||
page.locator("#cmp-link").get_attribute("aria-pressed"))
|
||||
page.locator("#cmp-link").focus()
|
||||
page.keyboard.press(" ")
|
||||
page.wait_for_timeout(300)
|
||||
pressed = (page.url.split("?")[1], page.locator("#cmp-link").get_attribute("aria-pressed"))
|
||||
page.locator("#cmp-link").press(" ") # linked again
|
||||
page.evaluate("document.activeElement.blur()")
|
||||
_press_and_wait(page, " ")
|
||||
fwd = _pair(page)
|
||||
_press_and_wait(page, "Shift+ ")
|
||||
back = _pair(page)
|
||||
_press_and_wait(page, "Escape")
|
||||
esc = page.url
|
||||
_press_and_wait(page, "c")
|
||||
c_review = _pair(page)
|
||||
_press_and_wait(page, "C")
|
||||
c_back = page.url
|
||||
page.close()
|
||||
assert inert == (True, "b", "true"), inert
|
||||
assert pressed == ("a=m-2-forge-s23.png&b=r-2-forge-s23.png&link=0", "false"), pressed
|
||||
assert fwd == ("m-3-marsh-s37.png", "r-3-marsh-s37.png") and back == ("m-2-forge-s23.png", "r-2-forge-s23.png"), (fwd, back)
|
||||
assert esc.endswith("/b/g/view?f=m-2-forge-s23.png"), esc
|
||||
assert c_review == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), c_review
|
||||
assert c_back.endswith("/b/g/view?f=m-2-forge-s23.png"), c_back
|
||||
Reference in New Issue
Block a user