'''
new = '''
'''
-[[mutation]]
-label = "C6 the next arrow's rail offset applies at phone width"
-file = "booth/templates/view.html"
-test = "tests/test_flow_browser.py::test_the_next_arrow_clears_the_rail_only_beside_it"
-old = ''' .vprev{left:0}.vnext{right:0}
- @media (min-width:901px){.vnext{right:360px}}
-'''
-new = ''' .vprev{left:0}.vnext{right:360px}
-'''
-
[[mutation]]
label = "resolver: an entry that cannot be stat'd raises out of booth_items"
file = "booth/items.py"
diff --git a/tests/mutations/r2c.toml b/tests/mutations/r2c.toml
new file mode 100644
index 0000000..f71857c
--- /dev/null
+++ b/tests/mutations/r2c.toml
@@ -0,0 +1,349 @@
+# R2c — the review stage: every falsifier the contract claims
+# (docs/contracts/r2c_review_stage.contract.md), and the change each forbids.
+
+unit = "the review stage: fit / 1:1, the arrows at the picture, drag-pan"
+
+[[mutation]]
+label = "S1 Fit never enlarges (the old max-width/max-height cap)"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
+old = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
+new = '''
+ .vstage.is-img img{width:auto;height:auto;max-width:100%;max-height:100%;object-fit:contain;'''
+
+[[mutation]]
+label = "S1 Fit crops (cover, not contain)"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
+old = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
+new = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:cover;'''
+
+[[mutation]]
+label = "S2 the toggle stays hidden (the per-picture hide is back)"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
+old = '''
+ toggle.hidden = false;'''
+new = '''
+ toggle.hidden = img.naturalWidth <= stage.clientWidth;'''
+
+[[mutation]]
+label = "S2 the toggle shows without JS (display beats [hidden])"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
+old = '''
+ .vtoggle[hidden]{display:none}'''
+new = '''
+'''
+
+[[mutation]]
+label = "S2 1:1 applied late (after the stage exists: a Fit flash)"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
+old = '''
+ if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
+new = '''
+ if (localStorage.getItem('booth.fit') === 'one') document.addEventListener('DOMContentLoaded', function () { d.classList.add('stage-one'); });'''
+
+[[mutation]]
+label = "S2 a stray stored value is taken as 1:1"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
+old = '''
+ if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
+new = '''
+ if (localStorage.getItem('booth.fit')) d.classList.add('stage-one');'''
+
+[[mutation]]
+label = "S2 a storage write that throws swallows the click"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
+old = '''
+ var setMode = function (one) {
+ d.classList.toggle('stage-one', one);'''
+new = '''
+ var setMode = function (one) {
+ localStorage.setItem('booth.fit', 'x');
+ d.classList.toggle('stage-one', one);'''
+
+[[mutation]]
+label = "S3 the arrows stay at the stage edges (never placed)"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
+old = '''
+ var p = drawn();
+ if (!p) {'''
+new = '''
+ var p = null;
+ if (!p) {'''
+
+[[mutation]]
+label = "S3 the arrows track the file's natural width, not the drawn picture"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
+old = '''
+ var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;'''
+new = '''
+ var k = 1, w = img.naturalWidth * k;'''
+
+[[mutation]]
+label = "S3 an arrow is not clamped inside the stage"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
+old = '''
+ x = Math.max(lo, Math.min(hi, x));'''
+new = '''
+'''
+
+# NEITHER path: the first draft of this row disabled only the ResizeObserver and
+# fell through to the window listener, so it stayed green — vacuous.
+[[mutation]]
+label = "S3 the arrows do not follow a resize"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
+old = '''
+ if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
+ else window.addEventListener('resize', settle);'''
+new = '''
+'''
+
+[[mutation]]
+label = "S4 the pan runs backwards (the picture flees the pointer)"
+file = "booth/templates/view.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;'''
+new = '''
+ stage.scrollLeft = drag.l + dx;'''
+
+[[mutation]]
+label = "S4 no drag threshold (a jittery click pans)"
+file = "booth/templates/view.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 */'''
+new = '''
+'''
+
+[[mutation]]
+label = "S4 the picture is draggable again"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
+old = '''alt="{{ file }}" draggable="false">'''
+new = '''alt="{{ file }}">'''
+
+[[mutation]]
+label = "S4 no grab cursor on a pannable 1:1 picture"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
+old = '''
+ .stage-one .vstage.can-pan{cursor:grab;user-select:none}'''
+new = '''
+ .stage-one .vstage.can-pan{user-select:none}'''
+
+[[mutation]]
+label = "S4 the stage reveal back inside the scrolled content (a pan carries it off)"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away"
+old = '''
+ .review-body > .reveal{position:absolute;top:14px;left:14px;z-index:5;'''
+new = '''
+ .review-body > .reveal{position:absolute;top:14px;left:14px;z-index:-1;'''
+
+# ---- the heid code-review fold ("VÍGUNDR")
+
+[[mutation]]
+label = "1:1 centres a large picture (its start side can never be scrolled to)"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable"
+old = '''
+ .stage-one .vstage.is-img{overflow:auto;padding:0;justify-content:flex-start;align-items:flex-start}'''
+new = '''
+ .stage-one .vstage.is-img{overflow:auto;padding:0}'''
+
+[[mutation]]
+label = "a buttonless hover continues a press released outside the stage"
+file = "booth/templates/view.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; }'''
+new = '''
+'''
+
+[[mutation]]
+label = "no pointer capture (a pan dies at the stage's edge)"
+file = "booth/templates/view.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) {}'''
+new = '''
+'''
+
+[[mutation]]
+label = "stacked, the arrows' fallback centres on stage AND rail"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen"
+old = '''
+ @media (max-width:900px){.vnav{top:30vh}}'''
+new = '''
+'''
+
+[[mutation]]
+label = "Fit shifts the picture off-centre (object-position), cropping it"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
+old = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain;'''
+new = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain;object-position:-100px 50%;'''
+
+[[mutation]]
+label = "the arrows are not centred on the stage"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage"
+old = '''
+ a.style.top = (s.top - o.top + s.height / 2) + 'px';'''
+new = '''
+ a.style.top = (s.top - o.top + s.height / 4) + 'px';'''
+
+[[mutation]]
+label = "a booth.fit read that throws raises out of the head script"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
+old = '''
+ try {
+ if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');
+ } catch (e) {}'''
+new = '''
+ if (localStorage.getItem('booth.fit') === 'one') d.classList.add('stage-one');'''
+
+[[mutation]]
+label = "the drag threshold is per axis, not total (a 3,3 diagonal pans nothing)"
+file = "booth/templates/view.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 */'''
+new = '''
+ if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;'''
+
+# ---- the heid bug-hunt fold ("ÞREKJUR"). Accepted, not rowed: no `touch-action`
+# (on touch the stage scrolls natively and the pan yields on pointercancel);
+# dragstart preventDefault beside draggable=false (defence in depth, one layer
+# alone holds).
+
+[[mutation]]
+label = "the stage reveal shows with scripts off (and does nothing)"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
+old = '''aria-label="reveal {{ file }}" hidden>'''
+new = '''aria-label="reveal {{ file }}">'''
+
+[[mutation]]
+label = "a revealed review picture loses Fit's shadow"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow"
+old = '''
+ .review .vstage.is-img.is-blurred.revealed img,.reveal-all .review .vstage.is-img.is-blurred img{'''
+new = '''
+ .review .vstage.is-img.is-blurred.revealed-OFF img,.reveal-all .review .vstage.is-img.is-blurred img{'''
+
+[[mutation]]
+label = "a stage mode chosen in another tab does not reach this one"
+file = "booth/templates/view.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;'''
+new = '''
+ return;'''
+
+[[mutation]]
+label = "the drag threshold drops to 3px"
+file = "booth/templates/view.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 */'''
+new = '''
+ if (dx * dx + dy * dy < 9) return; /* under 4px in all: a click */'''
+
+[[mutation]]
+label = "a storage write that throws cuts the click short (buttons never update)"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_mode_persists_across_prev_next_and_never_flashes"
+old = '''
+ try {
+ if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
+ } catch (e) {}'''
+new = '''
+ if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');'''
+
+[[mutation]]
+label = "the Fit button's pressed state is never drawn"
+file = "booth/templates/view.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');'''
+new = '''
+'''
+
+# ---- groa's retry supplement (code review)
+
+[[mutation]]
+label = "S1 Fit loses its drop shadow"
+file = "booth/templates/base.html"
+test = "tests/test_flow_browser.py::test_fit_fills_the_stage_up_or_down"
+old = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain;
+ filter:drop-shadow(0 10px 24px rgb(0 0 0 / .32))}'''
+new = '''
+ .vstage.is-img img{width:100%;height:100%;object-fit:contain}'''
+
+[[mutation]]
+label = "S2 the toggle hides, on load, for a picture larger than the stage"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_the_toggle_shows_for_every_picture_and_never_without_js"
+old = '''
+ function settle() { place(); pannable(); }'''
+new = '''
+ function settle() { place(); pannable(); 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"
+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');'''
+new = '''
+ if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.setItem('booth.fit', 'fit');'''
+
+[[mutation]]
+label = "S4 pan only when BOTH axes overflow"
+file = "booth/templates/view.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);'''
+new = '''
+ (stage.scrollWidth > stage.clientWidth && stage.scrollHeight > stage.clientHeight);'''
+
+# ---- groa's retry supplement (bug hunt): classic scrollbars
+
+[[mutation]]
+label = "a press on the stage's scrollbar starts a pan"
+file = "booth/templates/view.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 ||
+ e.clientY - r.top - stage.clientTop >= stage.clientHeight) return;'''
+new = '''
+'''
+
+[[mutation]]
+label = "the arrows clamp to the border box (the next one sits under a classic scrollbar)"
+file = "booth/templates/view.html"
+test = "tests/test_flow_browser.py::test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan"
+old = '''
+ var cl = s.left + stage.clientLeft, cr = cl + stage.clientWidth;'''
+new = '''
+ var cl = s.left, cr = s.right;'''
diff --git a/tests/test_flow.py b/tests/test_flow.py
index 3b66a50..3499376 100644
--- a/tests/test_flow.py
+++ b/tests/test_flow.py
@@ -586,7 +586,9 @@ def test_only_a_picture_gets_the_fit_toggle_and_blur_stays_honest(tmp_path):
c = _client(tmp_path)
pic = c.get("/b/g/view?f=a.png").text
assert 'id="vtoggle"' in pic and ">Fit<" in pic and ">1:1<" in pic
- assert 'class="vstage fit is-blurred"' in pic and "blur is cosmetic" in pic
+ # r2c: the mode moved to (never a Fit flash); the stage says only what it holds
+ assert 'class="vstage is-img is-blurred"' in pic and "blur is cosmetic" in pic
+ assert re.search(r']*\bcontrols\b", vid)
diff --git a/tests/test_flow_browser.py b/tests/test_flow_browser.py
index 20ed967..18f5882 100644
--- a/tests/test_flow_browser.py
+++ b/tests/test_flow_browser.py
@@ -394,22 +394,6 @@ def test_the_standalone_marks_page_updates_in_place(browser, live):
assert same
-def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
- """Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
- base.html's narrow override and won it, parking the arrow 360px in from
- the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
- base, root = live
- _set(root, 3)
- rights = {}
- for w in (1400, 390):
- page = browser.new_page(viewport={"width": w, "height": 900})
- page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
- rights[w] = page.evaluate(
- "getComputedStyle(document.querySelector('.vnav.vnext')).right")
- page.close()
- assert rights == {1400: "360px", 390: "0px"}
-
-
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
"""The row-controls change made a Desk row a flex container, and a long
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
@@ -538,7 +522,8 @@ def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(bro
ctx.close()
assert all("blur" in f for f in before), before
assert after == ["none", "none"], after
- assert review[:2] == ["none", "none"] and "blur again" in review[2], review
+ # revealed = no blur left; the review stage keeps Fit's drop shadow (r2c)
+ assert "blur(" not in review[0] and review[1] == "none" and "blur again" in review[2], review
assert "blur" in again, again
@@ -1083,3 +1068,434 @@ def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live):
page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")]
page.close()
assert got == ["none", "none"], got
+
+
+# ---- r2c: the review stage ---------------------------------------------------
+
+
+def _png(w: int, h: int, rgb=(90, 120, 160)) -> bytes:
+ """A real, decodable PNG of a given size (no Pillow needed): the stage's
+ geometry depends on NATURAL sizes, which the 8-byte stub has none of."""
+ import struct
+ import zlib
+ raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
+ def chunk(t, d):
+ return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
+ return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
+
+
+def _stage_set(root: pathlib.Path, pics: dict) -> pathlib.Path:
+ b = root / "g"
+ b.mkdir()
+ for name, (w, h) in pics.items():
+ (b / name).write_bytes(_png(w, h))
+ return b
+
+
+_GEOM = """() => {
+ const st = document.getElementById('vstage'), img = document.getElementById('vimg');
+ const r = e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; };
+ const cs = getComputedStyle(st);
+ const box = r(img), nw = img.naturalWidth, nh = img.naturalHeight;
+ const k = Math.min(box.w / nw, box.h / nh), dw = nw * k;
+ return {stage: r(st), inner: {w: st.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight),
+ h: st.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)},
+ img: box, nat: [nw, nh], fit: getComputedStyle(img).objectFit, pos: getComputedStyle(img).objectPosition, scale: k,
+ filter: getComputedStyle(img).filter,
+ drawn: {l: box.l + (box.w - dw) / 2, r: box.l + (box.w + dw) / 2},
+ prev: document.querySelector('.vnav.vprev') && r(document.querySelector('.vnav.vprev')),
+ next: document.querySelector('.vnav.vnext') && r(document.querySelector('.vnav.vnext')),
+ rail: r(document.getElementById('rail'))};
+}"""
+
+
+def _load(page, url):
+ page.goto(url, wait_until="networkidle")
+ page.wait_for_function("document.getElementById('vimg').complete && document.getElementById('vimg').naturalWidth > 0")
+ page.wait_for_timeout(150)
+
+
+def test_fit_fills_the_stage_up_or_down(browser, live):
+ """r2c S1 (operator ruling: 'Fit may enlarge'). In Fit the picture's box IS
+ the stage's inner box and `object-fit: contain` draws the whole picture at
+ min(W/w, H/h) — UP for a small one, down for a large one, never cropped.
+ In 1:1 it is its natural size."""
+ base, root = live
+ _stage_set(root, {"small.png": (200, 100), "big.png": (3000, 1500)})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ got = {}
+ for name in ("small.png", "big.png"):
+ _load(page, f"{base}/b/g/view?f={name}")
+ fit = page.evaluate(_GEOM)
+ page.locator("#btn-one").click()
+ page.wait_for_timeout(150)
+ one = page.evaluate(_GEOM)
+ page.locator("#btn-fit").click()
+ got[name] = (fit, one)
+ page.close()
+ for name, (fit, one) in got.items():
+ assert abs(fit["img"]["w"] - fit["inner"]["w"]) <= 1 and abs(fit["img"]["h"] - fit["inner"]["h"]) <= 1, (name, fit)
+ assert fit["fit"] == "contain" and fit["pos"] == "50% 50%", (name, fit["fit"], fit["pos"])
+ assert fit["filter"].startswith("drop-shadow"), ("the shadow follows the picture's pixels", fit["filter"])
+ assert [round(one["img"]["w"]), round(one["img"]["h"])] == one["nat"], (name, one["img"], one["nat"])
+ assert got["small.png"][0]["scale"] > 1.5 # enlarged
+ assert got["big.png"][0]["scale"] < 1 # reduced
+
+
+def test_the_toggle_shows_for_every_picture_and_never_without_js(browser, live):
+ """r2c S2: the per-picture hide is gone — a picture that fits at natural
+ size still gets Fit | 1:1. Audio gets none. Without JS it never shows."""
+ base, root = live
+ b = _stage_set(root, {"small.png": (200, 100), "large.png": (3000, 2000)})
+ (b / "t.mp3").write_bytes(b"ID3")
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=small.png")
+ pic = page.locator("#vtoggle").is_visible()
+ _load(page, f"{base}/b/g/view?f=large.png")
+ pic = pic and page.locator("#vtoggle").is_visible() # every picture, small or large
+ page.goto(f"{base}/b/g/view?f=t.mp3", wait_until="networkidle")
+ audio = page.locator("#vtoggle").count()
+ page.close()
+ ctx = browser.new_context(java_script_enabled=False)
+ nojs_page = ctx.new_page()
+ nojs_page.goto(f"{base}/b/g/view?f=small.png", wait_until="networkidle")
+ nojs = nojs_page.locator("#vtoggle").is_visible()
+ ctx.close()
+ assert pic and audio == 0 and not nojs, (pic, audio, nojs)
+
+
+_STAGE_WATCH = """
+window.__stageOne = null;
+new MutationObserver(function (m, obs) {
+ if (document.getElementById('vstage')) {
+ window.__stageOne = document.documentElement.classList.contains('stage-one');
+ obs.disconnect();
+ }
+}).observe(document, {childList: true, subtree: true});
+"""
+
+
+def test_the_mode_persists_across_prev_next_and_never_flashes(browser, live):
+ """r2c S2: 1:1 chosen on one picture holds on the next, and it is in force
+ BEFORE the stage exists — an observer set before any page script records
+ 's class as the parser inserts the stage. A stray stored value reads
+ as Fit; a storage write that throws still applies the click."""
+ base, root = live
+ _stage_set(root, {"a.png": (1600, 1200), "b.png": (1600, 1200)})
+ ctx = browser.new_context(viewport={"width": 1400, "height": 900})
+ page = ctx.new_page()
+ page.add_init_script(_STAGE_WATCH)
+ _load(page, f"{base}/b/g/view?f=a.png")
+ first = page.evaluate("window.__stageOne")
+ page.locator("#btn-one").click()
+ stored = page.evaluate("localStorage.getItem('booth.fit')")
+ page.keyboard.press("ArrowRight")
+ page.wait_for_url("**/view?f=b.png")
+ _load(page, page.url)
+ at_parse = page.evaluate("window.__stageOne")
+ pressed = page.locator("#btn-one").get_attribute("aria-pressed")
+ fit_pressed = page.locator("#btn-fit").get_attribute("aria-pressed")
+ # choosing Fit FORGETS 1:1 (removes the key; it does not store some other word)
+ page.locator("#btn-fit").click()
+ forgot = page.evaluate("localStorage.getItem('booth.fit')")
+ page.evaluate("localStorage.setItem('booth.fit', 'zoom')")
+ page.reload(wait_until="networkidle")
+ stray = page.evaluate("window.__stageOne")
+ ctx.close()
+ ctx = browser.new_context(viewport={"width": 1400, "height": 900})
+ page = ctx.new_page()
+ errors = []
+ page.on("pageerror", lambda e: errors.append(str(e)))
+ page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); };
+ Storage.prototype.getItem = function () { throw new Error('denied'); };""")
+ _load(page, f"{base}/b/g/view?f=a.png")
+ unreadable = page.evaluate("document.documentElement.classList.contains('stage-one')")
+ page.locator("#btn-one").click()
+ applied = page.evaluate("document.documentElement.classList.contains('stage-one')")
+ applied_pressed = page.locator("#btn-one").get_attribute("aria-pressed")
+ ctx.close()
+ assert unreadable is False and errors == [], (unreadable, errors)
+ assert applied_pressed == "true", "a write that throws must not cut the click short"
+ assert first is False and stored == "one", (first, stored)
+ assert at_parse is True and pressed == "true" and fit_pressed == "false", (at_parse, pressed, fit_pressed)
+ assert stray is False and applied is True and forgot is None, (stray, applied, forgot)
+
+
+def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser, live):
+ """r2c S3 (operator: 'arrows closer to the edge of the image instead of out
+ at the edges unless the image spans the entire width'). A portrait whose
+ NATURAL width exceeds the stage but which is DRAWN narrower (height-bound in
+ Fit): each arrow wholly outside the drawn picture, near edge 8px from it.
+ A landscape drawn as wide as the stage: arrows at the stage's edges, over
+ the picture. Never over the rail; after a resize they follow."""
+ base, root = live
+ _stage_set(root, {"a-tall.png": (1400, 2800), "b-wide.png": (3000, 900)})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=a-tall.png")
+ tall = page.evaluate(_GEOM)
+ _load(page, f"{base}/b/g/view?f=b-wide.png")
+ wide = page.evaluate(_GEOM)
+ page.set_viewport_size({"width": 1200, "height": 800})
+ page.goto(f"{base}/b/g/view?f=a-tall.png", wait_until="networkidle")
+ _load(page, page.url)
+ page.set_viewport_size({"width": 1300, "height": 850})
+ page.wait_for_timeout(300)
+ resized = page.evaluate(_GEOM)
+ page.close()
+ assert tall["nat"][0] > tall["stage"]["w"], "the fixture must be naturally wider than the stage"
+ for g in (tall, resized):
+ assert abs((g["drawn"]["l"] - 8) - g["prev"]["r"]) <= 2, (g["drawn"], g["prev"])
+ assert abs(g["next"]["l"] - (g["drawn"]["r"] + 8)) <= 2, (g["drawn"], g["next"])
+ for g in (tall, wide, resized):
+ assert g["prev"]["l"] >= g["stage"]["l"] and g["next"]["r"] <= g["stage"]["r"], g
+ mid = (g["stage"]["t"] + g["stage"]["b"]) / 2
+ for a in (g["prev"], g["next"]):
+ assert abs((a["t"] + a["b"]) / 2 - mid) <= 2, ("not centred on the stage", a, mid)
+ assert g["next"]["r"] <= g["rail"]["l"], "an arrow over the rail"
+ assert abs(wide["prev"]["l"] - (wide["stage"]["l"] + 8)) <= 2 and abs(wide["next"]["r"] - (wide["stage"]["r"] - 8)) <= 2, wide
+
+
+def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browser, live):
+ """r2c S4 (operator: 'mouse click and pan for 1:1 mode ... defeat drag drop
+ of image'). The picture follows the pointer: a drag of (+80, +60) scrolls
+ the stage by (-80, -60). A 2px press pans nothing; a press on the stage's
+ own reveal button reveals and does not pan; the picture is not draggable;
+ in Fit a drag scrolls nothing."""
+ from booth.app import set_blurred
+ base, root = live
+ b = _stage_set(root, {"huge.png": (3000, 3000)})
+ set_blurred(b, "huge.png", True)
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=huge.png")
+ page.evaluate("document.getElementById('vimg').closest('.vstage').scrollTo(0, 0)")
+ fit_before = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
+ st = page.locator("#vstage").bounding_box()
+ cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
+ fit_after = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
+ page.locator("#btn-one").click()
+ page.evaluate("document.getElementById('vstage').scrollTo(500, 500)")
+ scroll = lambda: page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
+ cursor = page.evaluate("getComputedStyle(document.getElementById('vstage')).cursor")
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
+ panned = scroll()
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 2, cy + 1, steps=2); page.mouse.up()
+ jitter = scroll()
+ # the threshold is 4px of TOTAL movement: (3, 2) is 3.6px and pans nothing;
+ # a (3, 3) diagonal is 4.24px, so it pans
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 3, cy + 2, steps=1); page.mouse.up()
+ under = scroll()
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 3, cy + 3, steps=1); page.mouse.up()
+ diagonal = scroll()
+ page.evaluate("document.getElementById('vstage').scrollTo(420, 440)")
+ # the stage's reveal sits over the stage, not in its scrolled content: at any
+ # scroll it is in view, a click on it reveals, and a drag from it pans nothing
+ btn = page.locator("#vreveal").bounding_box()
+ bx, by = btn["x"] + btn["width"] / 2, btn["y"] + btn["height"] / 2
+ page.mouse.move(bx, by); page.mouse.down(); page.mouse.move(bx + 80, by + 60, steps=6); page.mouse.up()
+ dragged_from_button = scroll()
+ page.locator("#vreveal").click()
+ revealed = page.evaluate("document.getElementById('vstage').classList.contains('revealed')")
+ after_button = scroll()
+ draggable = page.evaluate("document.getElementById('vimg').draggable")
+ page.close()
+ assert fit_before == fit_after, (fit_before, fit_after)
+ assert cursor == "grab", cursor
+ assert panned == [420, 440], panned
+ assert jitter == [420, 440] and dragged_from_button == [420, 440], (jitter, dragged_from_button)
+ assert under == [420, 440] and diagonal == [417, 437], (under, diagonal)
+ assert revealed and after_button == [420, 440], (revealed, after_button)
+ assert draggable is False
+
+
+def test_in_one_to_one_every_pixel_of_a_large_picture_is_reachable(browser, live):
+ """heid code-review (kimi; confirmed by measurement): the 1:1 stage kept the
+ flex CENTRING while adding overflow — a picture larger than the stage
+ overflowed BOTH sides, and the start side cannot be scrolled to. A 3000px
+ picture hid its leftmost 980px for good. At scroll (0, 0) the picture's
+ top-left is the stage's; at the far scroll its bottom-right is. A picture
+ smaller than the stage is still centred."""
+ base, root = live
+ _stage_set(root, {"a-huge.png": (3000, 3000), "b-small.png": (200, 100)})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=a-huge.png")
+ page.locator("#btn-one").click()
+ page.wait_for_timeout(150)
+ edges = """(to) => { const s = document.getElementById('vstage');
+ s.scrollTo(to === 'start' ? 0 : s.scrollWidth, to === 'start' ? 0 : s.scrollHeight);
+ const sr = s.getBoundingClientRect(), ir = document.getElementById('vimg').getBoundingClientRect();
+ return to === 'start' ? [Math.round(ir.left - sr.left), Math.round(ir.top - sr.top)]
+ : [Math.round(sr.left + s.clientWidth - ir.right), Math.round(sr.top + s.clientHeight - ir.bottom)]; }"""
+ start, end = page.evaluate(edges, "start"), page.evaluate(edges, "end")
+ _load(page, f"{base}/b/g/view?f=b-small.png")
+ small = page.evaluate("""() => { const s = document.getElementById('vstage'), sr = s.getBoundingClientRect(),
+ ir = document.getElementById('vimg').getBoundingClientRect();
+ return [Math.round((ir.left - sr.left) - (sr.left + s.clientWidth - ir.right)),
+ Math.round((ir.top - sr.top) - (sr.top + s.clientHeight - ir.bottom))]; }""")
+ page.close()
+ assert start == [0, 0] and end == [0, 0], (start, end)
+ assert all(abs(v) <= 1 for v in small), small # centred: equal margins both sides
+
+
+def test_a_pan_holds_past_the_stage_edge_and_never_starts_on_a_hover(browser, live):
+ """heid code-review (hulda, kimi): pointer capture was in no test — a drag
+ carried past the stage's edge must keep panning — and a press released
+ OUTSIDE the stage before the drag began left the drag armed, so a later
+ buttonless hover panned. Capture holds the gesture; no button, no pan."""
+ base, root = live
+ _stage_set(root, {"huge.png": (3000, 3000)})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=huge.png")
+ page.locator("#btn-one").click()
+ page.evaluate("document.getElementById('vstage').scrollTo(1500, 1500)")
+ scroll = lambda: page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
+ st = page.locator("#vstage").bounding_box()
+ cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
+ # 1. carried far past the stage's right edge (over the rail): still panning
+ page.mouse.move(cx, cy); page.mouse.down()
+ page.mouse.move(cx + 40, cy, steps=2)
+ page.mouse.move(cx + st["width"] / 2 + 250, cy, steps=12)
+ page.mouse.up()
+ past_edge = scroll()
+ # 2. pressed, then out of the stage in one jump, released outside, re-entered with no button
+ page.evaluate("document.getElementById('vstage').scrollTo(1500, 1500)")
+ page.mouse.move(cx, cy); page.mouse.down()
+ page.mouse.move(st["x"] + st["width"] + 150, cy, steps=1)
+ page.mouse.up()
+ page.mouse.move(cx + 60, cy + 40, steps=6)
+ hover = scroll()
+ page.close()
+ expected = 1500 - (st["width"] / 2 + 250)
+ assert abs(past_edge[0] - expected) <= 2 and past_edge[1] == 1500, (past_edge, expected)
+ assert hover == [1500, 1500], hover
+
+
+def test_before_placement_the_arrows_never_sit_over_the_rail_on_a_narrow_screen(browser, live):
+ """heid code-review (hulda): stacked (<=900px), the arrows' CSS spot —
+ their place with JS off, while loading, or after a failed load — centred
+ on the stage AND the rail below it. Now it is the stage's centre. And a
+ picture that fails to load leaves the arrows at that spot, unplaced."""
+ base, root = live
+ from booth.marks import write_note
+ b = _stage_set(root, {"a.png": (1600, 1200), "c.png": (1600, 1200)})
+ (b / "b-broken.png").write_bytes(b"\x89PNG\r\n\x1a\nnot really")
+ # a rail TALLER than the stage — the only case where centring on stage AND
+ # rail lands below the stage; a short rail hid the bug (this test's first
+ # draft was vacuous against removing the fix)
+ for i in range(14):
+ write_note(b, "a.png", f"note {i}: " + "a longer observation about this picture " * 3)
+ ctx = browser.new_context(java_script_enabled=False, viewport={"width": 390, "height": 844})
+ page = ctx.new_page()
+ page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
+ nojs = page.evaluate(_GEOM.replace("img.naturalWidth", "(img.naturalWidth || 1)").replace("img.naturalHeight", "(img.naturalHeight || 1)"))
+ ctx.close()
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ errors = []
+ page.on("pageerror", lambda e: errors.append(str(e)))
+ page.goto(f"{base}/b/g/view?f=b-broken.png", wait_until="networkidle")
+ page.wait_for_timeout(300)
+ broken = page.evaluate("[...document.querySelectorAll('.vnav')].map(a => a.classList.contains('is-placed'))")
+ page.close()
+ for a in (nojs["prev"], nojs["next"]):
+ assert a["b"] <= nojs["stage"]["b"] and a["t"] >= nojs["stage"]["t"], ("over the rail", a, nojs["stage"])
+ assert broken == [False, False] and errors == [], (broken, errors)
+
+
+def test_the_stage_reveal_never_shows_without_js_and_keeps_the_fit_shadow(browser, live):
+ """heid bug-hunt (hulda, regin): the stage's reveal rendered visible with
+ scripts off and did nothing; and a revealed picture lost Fit's shadow."""
+ from booth.app import set_blurred
+ base, root = live
+ b = _stage_set(root, {"a.png": (800, 600)})
+ set_blurred(b, "a.png", True)
+ ctx = browser.new_context(java_script_enabled=False)
+ page = ctx.new_page()
+ page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
+ nojs = page.locator("#vreveal").is_visible()
+ ctx.close()
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=a.png")
+ page.locator("#vreveal").click()
+ page.wait_for_timeout(400)
+ shadow = page.evaluate("getComputedStyle(document.getElementById('vimg')).filter")
+ page.close()
+ assert not nojs
+ assert shadow.startswith("drop-shadow"), shadow
+
+
+def test_a_stage_mode_chosen_in_one_tab_moves_the_others(browser, live):
+ """heid bug-hunt (regin): the theme follows a choice made in another tab;
+ the stage mode did not."""
+ base, root = live
+ _stage_set(root, {"a.png": (1600, 1200)})
+ ctx = browser.new_context(viewport={"width": 1400, "height": 900})
+ a, b = ctx.new_page(), ctx.new_page()
+ _load(a, f"{base}/b/g/view?f=a.png")
+ _load(b, f"{base}/b/g/view?f=a.png")
+ a.locator("#btn-one").click()
+ b.wait_for_function("document.documentElement.classList.contains('stage-one')", timeout=5000)
+ pressed = b.locator("#btn-one").get_attribute("aria-pressed")
+ ctx.close()
+ assert pressed == "true"
+
+
+def test_a_picture_that_overflows_one_axis_pans_along_it(browser, live):
+ """heid code-review supplement (groa): 'overflows EITHER axis' was untested;
+ requiring both would pass everything else. A wide, short picture in 1:1 —
+ wider than the stage, shorter than it — pans horizontally."""
+ base, root = live
+ _stage_set(root, {"wide.png": (3000, 200)})
+ page = browser.new_page(viewport={"width": 1400, "height": 900})
+ _load(page, f"{base}/b/g/view?f=wide.png")
+ page.locator("#btn-one").click()
+ page.evaluate("document.getElementById('vstage').scrollTo(800, 0)")
+ st = page.locator("#vstage").bounding_box()
+ cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
+ page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 100, cy, steps=6); page.mouse.up()
+ got = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').classList.contains('can-pan')]")
+ page.close()
+ assert got == [700, True], got
+
+
+_CLASSIC_SCROLLBARS = "#vstage::-webkit-scrollbar{width:15px;height:15px;background:#888}"
+
+
+def test_a_classic_scrollbar_is_neither_under_an_arrow_nor_a_pan(browser, live):
+ """heid bug-hunt supplement (groa): with classic scrollbars the next arrow
+ clamped against the stage's BORDER box and sat under the vertical bar, and
+ a press on the bar started a pan that fought the thumb backwards. Headless
+ Chromium draws overlay bars (no gutter), so this test forces a 15px classic
+ one — and asserts the gutter is real before it trusts a single measure."""
+ base, root = live
+ _stage_set(root, {"huge.png": (3000, 3000), "z.png": (3000, 3000)})
+ # Playwright launches headless Chromium with --hide-scrollbars, which hides
+ # even a styled bar: this test needs a browser without it
+ bars = browser.browser_type.launch(args=OFFLINE, ignore_default_args=["--hide-scrollbars"])
+ page = bars.new_page(viewport={"width": 1400, "height": 900})
+ page.add_init_script(f"document.addEventListener('DOMContentLoaded', () => {{ const st = document.createElement('style'); st.textContent = {_CLASSIC_SCROLLBARS!r}; document.head.appendChild(st); }});")
+ page.goto(f"{base}/b/g/view?f=huge.png", wait_until="networkidle")
+ page.wait_for_function("document.getElementById('vimg').naturalWidth > 0")
+ page.locator("#btn-one").click()
+ # `complete` can be true before the 3000px picture is LAID OUT; scrolling
+ # before then clamps to 0 (this test's first draft flaked 1 in 3 on it)
+ page.wait_for_function("(s => s.scrollWidth - s.clientWidth > 1500)(document.getElementById('vstage'))")
+ g = page.evaluate("""() => { const s = document.getElementById('vstage'), r = s.getBoundingClientRect(),
+ n = document.querySelector('.vnext').getBoundingClientRect();
+ return {gutter: s.offsetWidth - s.clientWidth, client_right: r.left + s.clientLeft + s.clientWidth,
+ next_right: n.right, r: {x: r.left, y: r.top, w: r.width, h: r.height}}; }""")
+ page.evaluate("document.getElementById('vstage').scrollTo(800, 800)")
+ # A press ON the vertical scrollbar, dragged sideways, dispatched as pointer
+ # events so the test measures OUR handler and not Chromium's native bar
+ # (real-mouse drags on the bar flaked 1 in 6 on native track behaviour that
+ # never reproduced standalone in 14 tries): no pan may move the picture.
+ left = page.evaluate("""() => { const s = document.getElementById('vstage'), r = s.getBoundingClientRect();
+ const x = r.left + r.width - 6, y = r.top + r.height / 2;
+ const ev = (t, dx, b) => s.dispatchEvent(new PointerEvent(t, {bubbles: true, pointerId: 7, button: 0,
+ buttons: b, clientX: x + dx, clientY: y, isPrimary: true}));
+ ev('pointerdown', 0, 1); ev('pointermove', -40, 1); ev('pointermove', -80, 1); ev('pointerup', -80, 0);
+ return s.scrollLeft; }""")
+ bars.close()
+ assert g["gutter"] >= 15, ("the forced classic scrollbar is not in effect", g)
+ assert g["next_right"] <= g["client_right"] - 8 + 1, g
+ assert left == 800, left