fix(review): the heid code-review and bug-hunt panels on r2c, folded (both 4/4 with retries)

- 1:1 start-aligns. The centred flex item overflowed both sides and the
  start was unreachable; measured, a 3000px picture hid its leftmost
  980px. Auto margins still centre a small picture.
- Drag lifecycle: a move with no button ends the drag, so a press
  released outside the stage never pans on a later hover. Capture is now
  load-bearing in a test. The threshold is 4px of total movement.
- A press on the stage's own scrollbar is never a pan. The arrows clamp
  to the stage's client box, so they are never under a classic
  scrollbar. The test runs a browser without --hide-scrollbars and
  asserts the gutter exists.
- Stacked, the arrows' CSS spot is the stage's centre (30vh), set in
  view.html because base.html lost to the page's later rule.
- The stage reveal is `hidden` until bound, and keeps Fit's drop shadow
  when revealed. A blurred picture composes blur() drop-shadow().
- The mode follows another tab. A failed or unknown size returns the
  arrows to their CSS spot.
- Tests: object-position, vertical centring, the Fit half of
  aria-pressed, a storage read that throws, a large picture's toggle,
  Fit forgetting 1:1, single-axis pan.
- Declared: the r2b reveal test reads "no blur" (the shadow stays), and
  the r2_flow 360px-offset row is retired.

Mutation tables 137/137 across four. 810 passed.
This commit is contained in:
vh
2026-09-24 00:20:15 -07:00
parent 7151a45ec2
commit 7c879e6038
6 changed files with 546 additions and 46 deletions
+226 -7
View File
@@ -522,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
@@ -1100,7 +1101,8 @@ _GEOM = """() => {
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, scale: k,
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')),
@@ -1134,7 +1136,8 @@ def test_fit_fills_the_stage_up_or_down(browser, live):
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", (name, fit["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
@@ -1144,11 +1147,13 @@ 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)})
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()
@@ -1190,20 +1195,31 @@ def test_the_mode_persists_across_prev_next_and_never_flashes(browser, live):
_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()
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };")
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", (at_parse, pressed)
assert stray is False and applied is True, (stray, applied)
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):
@@ -1233,6 +1249,9 @@ def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser,
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
@@ -1263,6 +1282,13 @@ def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browse
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()
@@ -1278,5 +1304,198 @@ def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browse
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