Files
booth/tests/test_flow_browser.py
T
vh 7c879e6038 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.
2026-09-24 00:20:15 -07:00

1502 lines
74 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""R2 C3/C5/C6 in a real DOM: in-place judgment.
A TestClient can prove what the server answers. It cannot prove that a flag
made in the page lands without a reload, that the regions come back fresh, or
that a failure never re-POSTs. So: a real uvicorn, a real Chromium — the same
harness as test_embed_browser.py, and like it this SKIPS, never fails, when no
browser is available.
"""
import contextlib
import pathlib
import socket
import sys
import threading
import time
import pytest
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
playwright_api = pytest.importorskip("playwright.sync_api", reason="playwright is not installed")
PNG = b"\x89PNG\r\n\x1a\n"
# NO INTERNET for the test browser. Every Booth page asks fonts.googleapis.com
# for its faces, and "networkidle" waits for that request — so a stalled request
# to Google hung the page until goto's 30s timeout, the failure mode of the
# full-suite flake (Page.goto timeouts in tests far apart in one run; a stalled
# font request reproduces it exactly). Whether that was THE cause is unproven;
# a test that depends on Google being reachable is wrong regardless. Every
# hostname but 127.0.0.1 now fails DNS at once, and the pages fall back to the
# system stacks the tokens declare. Positive control: test_*_has_no_internet.
OFFLINE = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"]
@pytest.fixture(scope="module")
def browser():
with playwright_api.sync_playwright() as pw:
try:
b = pw.chromium.launch(args=OFFLINE)
except Exception as exc: # noqa: BLE001 - any launch failure is a skip
pytest.skip(f"no usable chromium: {exc}")
yield b
b.close()
@contextlib.contextmanager
def _serving(data_dir: pathlib.Path):
"""A real uvicorn on a free port over `data_dir`. A context manager rather
than only a fixture, so a test can serve from a data dir of its own shape
(a long install path, say)."""
import uvicorn
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
# ⚠ THE SOCKET IS HANDED TO UVICORN STILL BOUND, never closed and
# re-opened by port number. The old form did bind -> getsockname -> CLOSE ->
# tell uvicorn the number, which leaves a window where the kernel can give
# that port to somebody else — and this suite runs TWO browser files that
# each start a server per test, so the other one is right there competing
# for it. Passing the live socket removes the window rather than narrowing
# it.
#
# Honest about the evidence: two different browser tests failed once each
# across full-suite runs while passing 3/3 and 5/5 on their own, which is
# the signature of contention. We cannot prove from two samples that this
# race was the cause. It is a real defect either way, and it is the only
# one visible in the harness.
app = create_app(data_dir, ttl_hours=24, start_sweeper=False)
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
thread.start()
deadline = time.time() + 10
while not server.started and time.time() < deadline:
time.sleep(0.02)
if not server.started:
pytest.skip("uvicorn did not come up")
try:
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
thread.join(timeout=10)
@pytest.fixture
def live(tmp_path):
with _serving(tmp_path) as base:
yield base, tmp_path
def _set(root: pathlib.Path, n: int = 30) -> pathlib.Path:
b = root / "g"
b.mkdir()
for i in range(1, n + 1):
(b / f"{i:02d}.png").write_bytes(PNG)
return b
def test_a_flag_lands_in_place_and_every_region_catches_up(browser, live):
"""Click a tile's flag far down the page: no reload, the scroll position
holds, and the tile, the tray and the rail's count all show the server's
new state — the three places one flag appears."""
base, root = live
_set(root)
posts = []
page = browser.new_page(viewport={"width": 1400, "height": 800})
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
tile = page.locator('figure.item[data-item="24.png"]')
tile.scroll_into_view_if_needed()
y = page.evaluate("window.scrollY")
tile.locator(".flagtoggle button").click()
page.wait_for_selector('figure.item.is-flagged[data-item="24.png"]', timeout=10000)
state = page.evaluate("""() => ({
reload: window.__noReload !== 1,
y: window.scrollY,
tray: [...document.querySelectorAll('.tray .tray-ord')].map(e => e.textContent),
count: document.querySelector('.rail-f[data-filter="flagged"] b').textContent,
})""")
page.close()
assert not state["reload"]
assert abs(state["y"] - y) < 4, "the page must not jump"
assert state["tray"] == ["#24"]
assert state["count"] == "1"
assert len(posts) == 1
def test_a_failed_save_says_so_reloads_and_never_re_posts(browser, live):
"""The server refuses the write (the marks file went bad under the page).
The script must not retry — a retry after a lost response would duplicate
the judgment — it says so and reloads to show the server's truth."""
base, root = live
b = _set(root, 3)
posts = []
page = browser.new_page()
page.on("request", lambda r: posts.append(r.url) if r.method == "POST" else None)
page.goto(f"{base}/b/g/", wait_until="networkidle")
(b / ".marks.json").write_text("{damaged")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
# the reader is TOLD before the page goes (Wren, hulda: nothing asserted it)
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
said = page.locator('[data-region="status"]').inner_text()
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # past the reload
page.close()
assert "Could not save in place" in said
assert len(posts) == 1, f"re-POSTed: {posts}"
def test_a_lost_response_after_a_landed_write_is_never_retried(browser, live):
"""The case the never-re-POST rule exists for: the server WROTE the note,
then the response was lost. A retry would write it twice. The route lets
the request reach the server and then drops the reply."""
from booth.marks import marks_for
base, root = live
b = _set(root, 2)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
def drop_after_write(route):
route.fetch() # the write lands
route.abort() # ...and the browser never hears back
page.route("**/b/g/note", drop_after_write)
page.locator(".verdict .mark-add textarea").fill("exactly once")
page.locator(".verdict .mark-add button").click()
page.wait_for_selector('[data-region="status"]:not([hidden])', timeout=5000)
page.wait_for_timeout(1500)
page.close()
notes = [m for m in marks_for(b) if m.shape == "note" and m.text == "exactly once"]
assert len(notes) == 1, f"written {len(notes)} times"
def test_a_tile_the_fresh_page_no_longer_has_stays_put_marked_stale(browser, live):
"""Un-flag under ?filter=flagged: the fresh page has no such tile. It is
left where it is (nothing shifts under the reader) and marked stale."""
from booth.marks import set_flag
base, root = live
b = _set(root, 3)
set_flag(b, "01.png", True)
set_flag(b, "02.png", True)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/?filter=flagged", wait_until="networkidle")
page.locator('figure.item[data-item="01.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-stale[data-item="01.png"]', timeout=10000)
tiles = page.eval_on_selector_all("figure.item", "els => els.map(e => e.dataset.item)")
page.close()
assert tiles == ["01.png", "02.png"]
def test_the_review_keys_judge_in_place_and_stay_out_of_the_note(browser, live):
"""At full size: F typed into the note is a letter; F outside it flags IN
PLACE (the filmstrip underline and the tape catch up, no reload); Space
moves on; Esc goes back to the grid at the tile you were on."""
base, root = live
_set(root, 4)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=02.png", wait_until="networkidle")
page.evaluate("window.__noReload = 1")
page.locator("#vnote-text").click()
page.keyboard.type("fff")
assert page.locator(".vflag-btn.is-flagged").count() == 0
assert page.locator("#vnote-text").input_value() == "fff"
page.locator(".vr-where").click() # focus back on the page, not a field
page.keyboard.press("f")
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
state = page.evaluate("""() => ({
reload: window.__noReload !== 1,
film: [...document.querySelectorAll('.film-f.is-flagged .film-ord')].map(e => e.textContent),
draft: document.getElementById('vnote-text').value,
})""")
assert not state["reload"]
assert state["film"] == ["#2"]
assert state["draft"] == "fff", "an unsaved note must survive a swap it was not part of"
page.keyboard.press("n")
focused = page.evaluate("document.activeElement.id")
assert focused == "vnote-text", "N focuses the note"
page.locator(".vr-where").click()
with page.expect_navigation():
page.keyboard.press(" ")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("ArrowRight")
assert page.url.endswith("/b/g/view?f=04.png")
with page.expect_navigation():
page.keyboard.press("ArrowLeft")
assert page.url.endswith("/b/g/view?f=03.png")
with page.expect_navigation():
page.keyboard.press("Escape")
assert page.url.endswith("/b/g/#item-03.png")
page.close()
def test_the_stage_survives_an_in_place_save_and_a_focused_radio_keeps_f(browser, live):
"""The stage is never a region: the same node must still be on the page
after a flag lands (a playing track would restart otherwise). And F while
a radio — an <input> — has focus is not a flag."""
from booth.marks import declare_pick
base, root = live
b = _set(root, 2)
declare_pick(b, "q", {"prompt": "Sharp?", "options": ["yes", "no"]}, target="01.png")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
page.evaluate("document.getElementById('vstage').__mark = 1")
page.locator('.vrail input[type=radio]').first.focus()
page.keyboard.press("f")
page.wait_for_timeout(600)
assert page.locator(".vflag-btn.is-flagged").count() == 0, "F in a radio is not a flag"
page.locator(".vr-where").click()
page.keyboard.press("f")
page.wait_for_selector(".vflag-btn.is-flagged", timeout=10000)
same = page.evaluate("document.getElementById('vstage').__mark === 1")
page.close()
assert same, "the stage was replaced by the swap"
@pytest.mark.parametrize("js", [True, False])
def test_on_a_narrow_screen_flags_and_notes_fold_and_on_a_wide_one_they_show(browser, live, js):
"""C5 (Wren W1, 4/4): under 1000px the verdict stacks ABOVE the set, and
its flags and notes fold into <details> so the question is not buried —
with no script. Wide, they are simply shown. Checked with JS on and off."""
from booth.marks import set_flag, write_note
base, root = live
b = _set(root, 3)
set_flag(b, "02.png", True)
write_note(b, None, "a booth note")
for width, shown in ((390, False), (1400, True)):
ctx = browser.new_context(viewport={"width": width, "height": 900}, java_script_enabled=js)
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
tray = page.locator(".verdict .tray-item").first
note = page.locator(".verdict .mark-text", has_text="a booth note")
assert tray.is_visible() is shown, (width, js, "tray")
assert note.is_visible() is shown, (width, js, "note")
if not shown: # folded, but one tap away
page.locator(".verdict summary.v-fold-head").first.click()
assert tray.is_visible()
ctx.close()
# ---- fixups from the heid bug-hunt panel (round "Nyx") ------------------------
def test_the_link_board_still_confirms_before_removing_a_row(browser, live):
"""Nyx N1 (2/4): the R2 rewrite of booth.html's scripts deleted the board's
multi-select + confirmation script along with the handlers it replaced.
Removing a row is destructive; the confirm naming it must still stand in
front of the POST, and select-all must still select."""
base, root = live
board = root / "links"
board.mkdir()
(board / "links.md").write_text(
"- [one](http://x/1) <sub>· a · 2026-09-01 10:00</sub>\n"
"- [two](http://x/2) <sub>· a · 2026-09-01 10:01</sub>\n")
page = browser.new_page()
page.goto(f"{base}/b/links/", wait_until="networkidle")
dialogs = []
page.on("dialog", lambda d: (dialogs.append(d.message), d.dismiss()))
page.locator(".board-rm-btn").first.click()
page.wait_for_timeout(300)
page.locator("#board-selall").check()
ticked = page.eval_on_selector_all(".board-check", "els => els.filter(e => e.checked).length")
page.close()
assert dialogs and "Remove this link?" in dialogs[0]
assert ticked == 2
assert (board / "links.md").read_text().count("- [") == 2, "a dismissed confirm removed nothing"
def test_an_unsaved_choice_survives_a_save_elsewhere_and_a_double_click_writes_once(browser, live):
"""Nyx N5 (hulda, regin, groa): a picked-but-unsent radio was reset by any
other in-place save, drafts were matched by POSITION, and a double-click on
Add note wrote two notes. Now: dirty controls carry by identity, and a form
already in flight ignores a second submit."""
from booth.marks import declare_pick, marks_for
base, root = live
b = _set(root, 3)
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('.verdict input[type=radio][value="y"]').check()
page.locator('figure.item[data-item="02.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-flagged[data-item="02.png"]', timeout=10000)
assert page.locator('.verdict input[type=radio][value="y"]').is_checked()
page.locator(".verdict .mark-add textarea").fill("once")
page.locator(".verdict .mark-add button").dblclick()
page.wait_for_timeout(1500)
page.close()
assert [m.text for m in marks_for(b) if m.shape == "note"] == ["once"]
# The first page refresh after a save is held back 1s in the CLIENT: the server
# renders it at once (so it carries only the first flag) and the browser sees
# it late. Localhost alone never loses that race, so without the hold the test
# passed with sequencing deleted — it has to be forced to be a control.
_HOLD_FIRST_REFRESH = """
(function () {
var real = window.fetch, n = 0;
window.fetch = function (u, o) {
var p = real.apply(this, arguments);
if ((!o || !o.method || o.method === 'GET') && n++ === 0) {
return p.then(function (r) {
return new Promise(function (res) { setTimeout(function () { res(r); }, 1000); });
});
}
return p;
};
})();
"""
def test_quick_successive_flags_all_show(browser, live):
"""Nyx (hulda): with no sequencing, an older refresh landing after a newer
one showed the newer flag as gone. Saves are serialized."""
base, root = live
_set(root, 4)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.add_init_script(_HOLD_FIRST_REFRESH)
page.goto(f"{base}/b/g/", wait_until="networkidle")
for rel in ("01.png", "02.png", "03.png"):
page.locator(f'figure.item[data-item="{rel}"] .flagtoggle button').click()
page.wait_for_timeout(150)
page.wait_for_function(
"document.querySelectorAll('figure.item.is-flagged').length === 3", timeout=10000)
page.wait_for_timeout(1500)
n = page.locator("figure.item.is-flagged").count()
page.close()
assert n == 3
def test_the_standalone_marks_page_updates_in_place(browser, live):
"""Nyx (kimi): the marks page's forms are in-place, but the page had no
region, so an answer saved and the page never showed it."""
from booth.marks import declare_pick
base, root = live
b = _set(root, 1)
declare_pick(b, "q", {"prompt": "Which?", "options": ["x", "y"]})
page = browser.new_page()
page.goto(f"{base}/b/g/marks", wait_until="networkidle")
page.evaluate("window.__same_page = 1")
page.locator('input[type=radio][value="x"]').check()
page.locator(".mark-submit").click()
page.wait_for_selector(".mark.is-answered", timeout=10000)
# In place, not the reload fallback: the page's own window survived.
same = page.evaluate("window.__same_page === 1")
page.close()
assert same
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
width: at 390px the page scrolled sideways to 1029px. The column is capped
at the space it has; the ellipsis does the rest.
Slate (heid bug-hunt) widened it three ways: the invariant is PAGE-level,
so a long install path — printed in the footer and the empty state — must
wrap too (groa); the widths between the two first tested (700-1000px, a
non-wrapping flex line) are swept with the heaviest row the Desk draws
(regin); and the page is compared with its OWN client width, never the
viewport's, which a vertical scrollbar would make a false failure (kimi)."""
from booth.manifest import write_manifest
from booth.marks import declare_pick, set_flag
# ONE unbreakable run: a hyphen or a slash is a line-break opportunity, and
# the first draft of this fixture ("a-very-long-install-path-" * 9) wrapped
# all by itself — the test passed with the bug present.
root = tmp_path / ("averylonginstallpath" * 11)
root.mkdir()
over = {}
with _serving(root) as base:
for w in (390, 1400): # the empty Desk first
page = browser.new_page(viewport={"width": w, "height": 844})
page.goto(f"{base}/", wait_until="networkidle")
over[("empty", w)] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
page.close()
d = root / ("heavy-" + "x" * 60)
d.mkdir()
for i in range(12):
(d / f"{i:02d}.png").write_bytes(PNG)
set_flag(d, f"{i:02d}.png", True)
declare_pick(d, "q", {"prompt": "Which?", "options": ["x", "y"]})
(d / ".uploaded").write_bytes(b"")
write_manifest(d, "design-dev", title="A set with a long title " * 4,
why="a reason long enough to overflow any phone " * 6)
for w in (390, 720, 850, 1000, 1400):
page = browser.new_page(viewport={"width": w, "height": 844})
page.goto(f"{base}/", wait_until="networkidle")
over[("heavy", w)] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
page.close()
assert all(v <= 0 for v in over.values()), over
def test_the_wipe_dialog_shows_what_is_being_wiped_and_never_fails_open(browser, live):
"""Slate T4 (groa): the booth name travels as data, never into a script —
but the confirm TEXT showed it raw, so a name carrying a bidi override
(U+202E) or a newline rewrote what the operator reads before approving a
wipe. Controls and bidi formatting show as U+FFFD instead. And (seat S1)
a `data-confirm` word the page does not know submitted with NO prompt; an
unknown word now asks generically — fail closed, never open."""
import os
base, root = live
name = "safe‮gnp.xe\nline2"
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
past = time.time() - 10_000
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
page = browser.new_page(viewport={"width": 1400, "height": 900})
said = []
page.on("dialog", lambda dlg: (said.append(dlg.message), dlg.dismiss()))
page.goto(f"{base}/", wait_until="networkidle")
# r2b D1: the row's controls appear on hover (operator ruling), so hover first
page.locator(".desk-row").first.hover()
page.locator("form.wipe button").first.click()
page.wait_for_timeout(300)
page.evaluate("""() => { const f = document.createElement('form');
f.method = 'post'; f.action = '/nowhere'; f.setAttribute('data-confirm', 'typo');
f.setAttribute('data-booth', 'x'); f.innerHTML = '<button>go</button>';
document.body.appendChild(f); f.querySelector('button').click(); }""")
page.wait_for_timeout(300)
page.close()
assert d.exists(), "a dismissed confirm must not wipe"
assert len(said) == 2, said
shown = said[0].split("\n\n")[0]
assert not any(c in shown for c in "‮\n"), repr(shown)
assert "safe�gnp.xe�line2" in shown, repr(shown)
_FILTER = "sel => { const e = document.querySelector(sel); return e ? getComputedStyle(e).filter : 'MISSING'; }"
def _blurred_set(root: pathlib.Path, name: str = "g", blur=("a.png",), flag=("a.png",)) -> pathlib.Path:
from booth.app import set_blurred
from booth.marks import set_flag
b = root / name
b.mkdir()
for rel in ("a.png", "b.png", "c.png"):
(b / rel).write_bytes(PNG)
for rel in blur:
set_blurred(b, rel, True)
for rel in flag:
set_flag(b, rel, True)
return b
def _settle(page) -> None:
page.wait_for_timeout(400) # the filter transition (--dur-2)
def test_reveal_all_reveals_every_blurred_surface_and_survives_the_next_page(browser, live):
"""r2b D2 (blur ruling A): one click lifts the blur on the tile, the tray,
the review stage and the filmstrip, and it holds on the next page of the
same tab. A fresh tab is blurred again — per tab, never persisted."""
base, root = live
_blurred_set(root)
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
tile, tray = 'figure.item[data-item="a.png"] img', ".tray-item.is-blurred img"
before = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
page.locator("[data-reveal-all]").click()
_settle(page)
after = [page.evaluate(_FILTER, tile), page.evaluate(_FILTER, tray)]
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
_settle(page)
review = [page.evaluate(_FILTER, "#vimg"), page.evaluate(_FILTER, ".film-f.is-blurred img"),
page.locator("[data-reveal-all]").inner_text()]
fresh = ctx.new_page() # a new tab: sessionStorage is per tab
fresh.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
_settle(fresh)
again = fresh.evaluate(_FILTER, "#vimg")
ctx.close()
assert all("blur" in f for f in before), before
assert after == ["none", "none"], after
# 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
def test_reveal_all_on_booth_a_does_not_reveal_booth_b_or_the_desk(browser, live):
"""r2b D2 + INV-4: the reveal is scoped to the booth you are in. Booth A's
cannot follow you into booth B, and nothing on the index is revealed."""
base, root = live
_blurred_set(root, "ga")
_blurred_set(root, "gb")
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/ga/", wait_until="networkidle")
page.locator("[data-reveal-all]").click()
page.goto(f"{base}/b/gb/", wait_until="networkidle")
_settle(page)
b_tile = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.goto(f"{base}/", wait_until="networkidle")
desk = page.evaluate("""() => [...document.querySelectorAll('.desk-strip img.blurred-thumb')]
.map(i => getComputedStyle(i).filter)""")
page.close()
assert b_tile == "blur(22px)", b_tile
assert desk and all(f == "blur(16px)" for f in desk), desk # exact: blur(0px) is not blurred
def test_reveal_all_survives_an_in_place_save(browser, live):
"""r2b D2: after an in-place save the blur is still lifted — the swapped-in
tile included — the control still says "blur again" and still works, and
the per-tile reveal buttons are still stood down."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("window.__same_page = 1")
page.locator("[data-reveal-all]").click()
page.locator('figure.item[data-item="b.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-flagged[data-item="b.png"]', timeout=10000)
_settle(page)
got = {"same": page.evaluate("window.__same_page === 1"),
"b": page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'),
"label": page.locator("[data-reveal-all]").inner_text(),
"tile_btn": page.evaluate("""() => getComputedStyle(
document.querySelector('figure.item[data-item="b.png"] .reveal')).display""")}
page.locator("[data-reveal-all]").click()
_settle(page)
got["back"] = page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img')
page.close()
assert got["same"] and got["b"] == "none" and "blur again" in got["label"], got
assert got["tile_btn"] == "none" and "blur" in got["back"], got
def test_blur_again_restores_each_items_own_reveal(browser, live):
"""r2b D2: Reveal all never touches an item's own reveal, so "blur again"
returns each item exactly as it was — one revealed on its own stays so."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="a.png"] .reveal').click()
page.locator("[data-reveal-all]").click()
page.locator("[data-reveal-all]").click()
_settle(page)
got = [page.evaluate(_FILTER, f'figure.item[data-item="{r}"] img') for r in ("a.png", "b.png")]
page.close()
assert got[0] == "none" and "blur" in got[1], got
def test_reveal_all_never_shows_without_js_and_a_storage_failure_still_applies_the_click(browser, live):
"""r2b D2: without JS the control is in the markup but never shown. With
sessionStorage throwing on write (a private window), the click still
applies to the page — only the memory is lost."""
base, root = live
_blurred_set(root)
ctx = browser.new_context(java_script_enabled=False)
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
nojs = page.locator("[data-reveal-all]").is_visible()
ctx.close()
page = browser.new_page(viewport={"width": 1400, "height": 900})
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
# READS and WRITES both throw: the pre-paint read must degrade to "not
# revealed" without raising, and the click must still apply.
page.add_init_script("""Storage.prototype.setItem = function () { throw new Error('quota'); };
Storage.prototype.getItem = function () { throw new Error('denied'); };""")
page.goto(f"{base}/b/g/", wait_until="networkidle")
_settle(page)
before = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.locator("[data-reveal-all]").click()
_settle(page)
lifted = page.evaluate(_FILTER, 'figure.item[data-item="a.png"] img')
page.close()
assert not nojs
assert before == "blur(22px)" and lifted == "none", (before, lifted)
assert errors == [], errors
def test_an_items_own_reveal_survives_an_in_place_save(browser, live):
"""heid code-review (groa, kimi): nothing pinned the swap carrying an item's
own `revealed` — deleting it from the carry list left every test green.
Reveal one tile, save something else in place: it stays revealed, and its
button still says so."""
base, root = live
_blurred_set(root, blur=("a.png", "b.png"), flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator('figure.item[data-item="b.png"] .reveal').click()
page.locator('figure.item[data-item="a.png"] .flagtoggle button').click()
page.wait_for_selector('figure.item.is-flagged[data-item="a.png"]', timeout=10000)
_settle(page)
got = [page.evaluate(_FILTER, 'figure.item[data-item="b.png"] img'),
page.locator('figure.item[data-item="b.png"] .reveal').inner_text()]
page.close()
assert got[0] == "none" and "hide" in got[1], got
def test_reveal_all_lifts_a_blurred_docs_own_page(browser, live):
"""The doc page's blur (heid code-review) obeys the same <html> class, and
its own reveal works."""
from booth.app import set_blurred
base, root = live
b = root / "g"
b.mkdir()
(b / "n.md").write_text("# secret\n\nbody")
set_blurred(b, "n.md", True)
page = browser.new_page(viewport={"width": 1200, "height": 800})
page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle")
_settle(page)
at_rest = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview")
page.locator("#docreveal").click()
_settle(page)
own = page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview")
page.close()
assert at_rest == "blur(22px)" and own == "none", (at_rest, own)
def test_space_on_a_focused_review_button_presses_it_and_does_not_move_on(browser, live):
"""heid bug-hunt (hulda): the review's document-level Space handler moved to
the next item before a focused button could take the key, so a keyboard
user could not press Reveal all or the fog control with Space."""
base, root = live
_blurred_set(root, flag=())
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle")
page.locator("[data-reveal-all]").focus()
page.keyboard.press(" ")
_settle(page)
got = [page.url, page.evaluate("document.documentElement.classList.contains('reveal-all')")]
page.close()
assert got[0].endswith("/b/g/view?f=a.png") and got[1], got
def test_the_review_and_doc_top_bars_fit_a_phone(browser, live):
"""heid bug-hunt (hulda, groa; needs-repro): the top bar gained the fog
control and Reveal all beside the name, the fit toggle and the download.
At 390px no review or doc page scrolls sideways, even with a long name."""
from booth.app import set_blurred
base, root = live
b = _blurred_set(root, flag=())
long = "a-rather-long-picture-name-" * 3 + ".png"
(b / long).write_bytes(PNG)
set_blurred(b, long, True)
(b / "n.md").write_text("# n")
set_blurred(b, "n.md", True)
over = {}
for url in (f"/b/g/view?f={long}", "/b/g/view?f=n.md"):
page = browser.new_page(viewport={"width": 390, "height": 844})
page.goto(f"{base}{url}", wait_until="networkidle")
over[url] = page.evaluate(
"document.documentElement.scrollWidth - document.documentElement.clientWidth")
# and nothing squeezed into a stack: every top-bar control is one line
over[url + " tallest"] = page.evaluate("""() => Math.max(...[...document.querySelectorAll(
'.vbar button, .vbar .vbtn')].filter(e => e.offsetParent).map(e => e.getBoundingClientRect().height)) - 40""")
page.close()
assert all(v <= 0 for v in over.values()), over
def test_the_test_browser_has_no_internet(browser, live):
"""Positive control for OFFLINE (booth-dev's ask: see the fix in force,
don't assume it). An external host fails at once, and a Booth page — whose
fonts are external — still goes idle in well under the goto timeout."""
base, root = live
(root / "g").mkdir()
page = browser.new_page()
t = time.time()
with pytest.raises(Exception) as err:
page.goto("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans", timeout=10000)
external = time.time() - t
page.close()
page = browser.new_page()
t = time.time()
page.goto(f"{base}/b/g/", wait_until="networkidle")
local = time.time() - t
page.close()
assert "ERR_NAME_NOT_RESOLVED" in str(err.value) and external < 3, (str(err.value)[:80], external)
assert local < 10, local
def _two_rows(root: pathlib.Path) -> None:
"""A kept and an ephemeral booth, both looked at since they landed (so no
'new' badge): the plainest rows the Desk draws."""
import os
past = time.time() - 10_000
for name, kept in (("kept1", True), ("loose", False)):
d = root / name
d.mkdir()
(d / "a.png").write_bytes(PNG)
os.utime(d / "a.png", (past, past))
(d / ".viewed").write_bytes(b"")
if kept:
(d / ".forever").write_bytes(b"")
_ROW_BOXES = """row => {
const box = e => { const r = e.getBoundingClientRect(); return [r.x, r.y, r.width, r.height].map(Math.round); };
const parts = [...row.querySelectorAll('.desk-strip, .desk-strip img, .desk-main, .desk-side, .life')];
return parts.map(box);
}"""
def test_the_row_controls_take_no_room_where_a_hover_exists(browser, live):
"""r2b D1 (operator: 'download, keep and release buttons only appear on
mouseover'; earlier: 'release and x take up space whether or not they're
visible'). Where a real hover exists the cluster floats over the preview
strip: at rest invisible AND unclickable; on hover visible AND clickable;
and every other box in the row is the same with the cluster removed. It
never covers the text or the side column, at any width."""
base, root = live
_two_rows(root)
over = {}
for w in (390, 720, 1000, 1400):
page = browser.new_page(viewport={"width": w, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
assert page.evaluate("matchMedia('(hover: hover) and (pointer: fine)').matches")
page.mouse.move(0, 0)
row = page.locator('.desk-row[data-booth="loose"]')
acts = row.locator(".desk-acts")
rest = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
with_acts = row.evaluate(_ROW_BOXES)
row.hover()
page.wait_for_timeout(350)
shown = acts.evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
over[w] = row.evaluate("""row => {
const a = row.querySelector('.desk-acts').getBoundingClientRect();
return [...row.querySelectorAll('.desk-main, .desk-side')].some(e => {
const r = e.getBoundingClientRect();
return a.left < r.right && r.left < a.right && a.top < r.bottom && r.top < a.bottom; }); }""")
acts.evaluate("a => a.remove()")
without = row.evaluate(_ROW_BOXES)
page.close()
assert rest == ["0", "none"], (w, rest)
assert shown == ["1", "auto"], (w, shown)
assert with_acts == without, (w, with_acts, without)
assert not any(over.values()), over
# and on hover a control is really pressable: keep reaches the server
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
row = page.locator('.desk-row[data-booth="loose"]')
row.hover()
page.wait_for_timeout(350)
with page.expect_navigation():
row.locator(".desk-acts form.keepit button").click()
page.close()
assert (root / "loose" / ".forever").exists()
def test_on_touch_the_row_controls_are_visible_in_flow_and_at_least_28px(browser, live):
"""r2b D1: hover-only would mean no controls at all on touch. Without a
real hover (here: a coarse, touch primary pointer) the cluster is visible,
in flow on its own line, and every control is at least the 28px floor
(Slate T2) — wipe clear of its neighbour."""
base, root = live
_two_rows(root)
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
coarse = page.evaluate("matchMedia('(pointer: coarse)').matches")
got = page.evaluate("""() => [...document.querySelectorAll('.desk-row')].map(row => {
const a = row.querySelector('.desk-acts'), cs = getComputedStyle(a);
const ctl = [...a.querySelectorAll('button, a')].map(b => b.getBoundingClientRect());
const main = row.querySelector('.desk-main').getBoundingClientRect();
const w = a.querySelector('form.wipe button').getBoundingClientRect();
const prev = ctl[ctl.length - 2];
return {opacity: cs.opacity, position: cs.position,
below: a.getBoundingClientRect().top >= main.bottom - 1,
small: ctl.filter(r => r.width < 28 || r.height < 28).length,
gap: Math.round(Math.max(w.left - prev.right, w.top - prev.bottom))}; })""")
ctx.close()
assert coarse, "the emulation must present a coarse pointer, or this test measures nothing"
for g in got:
assert g["opacity"] == "1" and g["position"] == "static" and g["below"], got
assert g["small"] == 0 and g["gap"] >= 8, got
def test_the_row_controls_run_zip_keep_or_release_then_wipe(browser, live):
"""r2b D1 (operator: 'the zip download button is in between keep/release and
wipe, and looks awkward'). Zip leads; release stays next to x."""
base, root = live
_two_rows(root)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
order = page.evaluate("""() => Object.fromEntries([...document.querySelectorAll('.desk-row')].map(row =>
[row.dataset.booth, [...row.querySelectorAll('.desk-acts > *')].map(e => e.className.split(' ')[0])]))""")
page.close()
assert order == {"kept1": ["dl-link", "release", "wipe"], "loose": ["dl-link", "keepit", "wipe"]}, order
_VAR = "n => getComputedStyle(document.documentElement).getPropertyValue(n).trim()"
def test_the_theme_toggle_forces_light_and_dark_and_system_follows_the_os_live(browser, live):
"""r2b D3 (operator: 'light mode toggle at the top (dark, light, system)').
Light and Dark force the theme and survive a reload in the same browser
(localStorage). System hands the question back to the OS, and follows it
LIVE — an OS flip moves the page with no reload and no listener, because a
media query tracks the OS by construction."""
base, root = live
_set(root, 1)
ctx = browser.new_context(color_scheme="dark", viewport={"width": 1200, "height": 800})
page = ctx.new_page()
page.goto(f"{base}/b/g/", wait_until="networkidle")
dark = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="light"]').click()
light = page.evaluate(_VAR, "--surface-base")
page.reload(wait_until="networkidle")
after_reload = page.evaluate(_VAR, "--surface-base")
pressed = page.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice")
page.locator('.theme [data-theme-choice="system"]').click()
system_dark = page.evaluate(_VAR, "--surface-base")
page.emulate_media(color_scheme="light")
system_light = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="dark"]').click()
forced_dark_on_light_os = page.evaluate(_VAR, "--surface-base")
ctx.close()
assert dark != light
assert after_reload == light and pressed == "light"
assert system_dark == dark and system_light == light, (system_dark, system_light)
assert forced_dark_on_light_os == dark
def test_a_forced_theme_follows_high_contrast(browser, live):
"""r2b D3: under prefers-contrast: more, a FORCED theme gets its own
high-contrast variant — forced dark resolves exactly what an OS-dark page
resolves, forced light exactly what an OS-light page does, whatever the OS
says about the scheme."""
base, root = live
_set(root, 1)
got = {}
for os_scheme in ("dark", "light"):
for forced in (None, "dark", "light"):
ctx = browser.new_context(color_scheme=os_scheme, reduced_motion="no-preference")
page = ctx.new_page()
page.emulate_media(color_scheme=os_scheme)
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.evaluate("f => f ? document.documentElement.setAttribute('data-theme', f) : null", forced)
# contrast: more is emulated through CDP; Playwright has no option for it
cdp = ctx.new_cdp_session(page)
cdp.send("Emulation.setEmulatedMedia", {"features": [
{"name": "prefers-contrast", "value": "more"},
{"name": "prefers-color-scheme", "value": os_scheme}]})
# tokens that DIFFER between a theme and its high-contrast variant
# (--surface-card does not, so reading it saw nothing — heid, 2/4)
got[(os_scheme, forced)] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default"))
if forced is None and os_scheme == "dark":
cdp.send("Emulation.setEmulatedMedia", {"features": [
{"name": "prefers-contrast", "value": "no-preference"},
{"name": "prefers-color-scheme", "value": os_scheme}]})
got["dark, no contrast"] = (page.evaluate(_VAR, "--text-faint"), page.evaluate(_VAR, "--border-default"))
ctx.close()
assert got[("light", "dark")] == got[("dark", None)] == got[("dark", "dark")]
assert got[("dark", "light")] == got[("light", None)] == got[("light", "light")]
assert got[("dark", None)] != got[("light", None)]
assert got[("dark", None)] != got["dark, no contrast"] # high contrast really applied
def test_the_theme_toggle_never_shows_without_js_and_a_storage_failure_still_applies(browser, live):
"""r2b D3: in the markup with `hidden`, never shown without JS (the page
follows the OS). With localStorage throwing, a click still applies to the
page; only the memory is lost."""
base, root = live
_set(root, 1)
ctx = browser.new_context(java_script_enabled=False)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
nojs = page.locator(".theme").is_visible()
ctx.close()
ctx = browser.new_context(color_scheme="dark")
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'); };""")
page.goto(f"{base}/", wait_until="networkidle")
before = page.evaluate(_VAR, "--surface-base")
page.locator('.theme [data-theme-choice="light"]').click()
after = page.evaluate(_VAR, "--surface-base")
ctx.close()
assert not nojs
assert before != after and errors == [], (before, after, errors)
def test_a_forced_theme_reaches_the_ask_chrome_inside_a_verbatim_page(browser, live):
"""r2b D3 (operator: 'theme toggle reaches inside'): the `.bk-ask` chrome
embed.js mounts in an author's page follows the stored choice, and a change
in another tab (the `storage` event) moves it live. The host page's own
<html> is never touched."""
from booth.marks import declare_pick
base, root = live
b = root / "rep"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
(b / "index.html").write_text('<!doctype html><title>r</title><body><h1>R</h1>'
'<script src="/_booth/embed.js" defer></script></body>')
ctx = browser.new_context(color_scheme="dark")
page = ctx.new_page()
page.goto(f"{base}/b/rep/", wait_until="networkidle")
page.wait_for_selector(".bk-ask")
accent = lambda: page.evaluate("getComputedStyle(document.querySelector('.bk-ask')).getPropertyValue('--bk-accent').trim()")
os_dark = accent()
other = ctx.new_page() # the toggle, pressed in another tab
other.goto(f"{base}/", wait_until="networkidle")
other.locator('.theme [data-theme-choice="light"]').click()
page.wait_for_timeout(300)
forced_light = accent()
host_html = page.evaluate("document.documentElement.getAttribute('data-theme')")
page.reload(wait_until="networkidle")
page.wait_for_selector(".bk-ask")
after_reload = accent()
ctx.close()
assert os_dark == "#b2cd12" and forced_light == "#586519" and after_reload == "#586519", (os_dark, forced_light, after_reload)
assert host_html is None
def test_a_rows_booth_name_comes_before_its_controls_in_tab_order(browser, live):
"""heid bug-hunt (groa): the cluster sat before the text in the markup, so
the first tab stop on a row was zip, then keep, then WIPE — before the booth
it acts on — and with scripts off, wipe submits with no confirm. The name
is first; the controls follow, wipe last."""
base, root = live
_two_rows(root)
ctx = browser.new_context(viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True)
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
order = page.evaluate("""() => { const row = document.querySelector('.desk-row[data-booth="loose"]');
return [...row.querySelectorAll('a[href], button')].filter(e => e.tabIndex >= 0)
.map(e => e.classList.contains('desk-title') ? 'title' : e.closest('form') ? e.closest('form').className.split(' ')[0] : e.className.split(' ')[0]); }""")
ctx.close()
assert order == ["title", "dl-link", "keepit", "wipe"], order
def test_a_theme_chosen_in_one_tab_moves_the_others(browser, live):
"""heid bug-hunt (groa): the ask chrome followed a choice made in another
tab, but the Booth's own open pages did not until reloaded."""
base, root = live
_set(root, 1)
ctx = browser.new_context(color_scheme="dark")
a, b = ctx.new_page(), ctx.new_page()
a.goto(f"{base}/", wait_until="networkidle")
b.goto(f"{base}/b/g/", wait_until="networkidle")
a.locator('.theme [data-theme-choice="light"]').click()
b.wait_for_function("document.documentElement.getAttribute('data-theme') === 'light'", timeout=5000)
pressed = b.locator('.theme [aria-pressed="true"]').get_attribute("data-theme-choice")
a.locator('.theme [data-theme-choice="system"]').click()
b.wait_for_function("!document.documentElement.hasAttribute('data-theme')", timeout=5000)
ctx.close()
assert pressed == "light"
def test_the_theme_marks_only_the_ask_fragments_we_mounted(browser, live):
"""heid bug-hunt (regin): the theme mark went on every `.bk-ask` in the
page, the author's own included. Only fragments the embed mounted."""
from booth.marks import declare_pick
base, root = live
b = root / "rep"
b.mkdir()
declare_pick(b, "winner", {"prompt": "Which?", "options": ["A", "B"]})
(b / "index.html").write_text('<!doctype html><title>r</title><body><div class="bk-ask" id="authors">mine</div>'
'<script src="/_booth/embed.js" defer></script></body>')
ctx = browser.new_context()
page = ctx.new_page()
page.add_init_script("try { localStorage.setItem('booth.theme', 'light'); } catch (e) {}")
page.goto(f"{base}/b/rep/", wait_until="networkidle")
page.wait_for_selector(".bk-ask:not(#authors)")
got = page.evaluate("""() => [document.getElementById('authors').getAttribute('data-bk-theme'),
[...document.querySelectorAll('.bk-ask:not(#authors)')].map(e => e.getAttribute('data-bk-theme'))]""")
ctx.close()
assert got[0] is None and got[1] and all(t == "light" for t in got[1]), got
def test_the_pill_shows_at_rest_and_focus_reveals_the_controls(browser, live):
"""heid code-review: the pill's "visible with no hover" was never asserted
(an opacity-0 box keeps its geometry), and nothing pinned KEYBOARD focus
revealing the controls — `:focus-within` could go alone."""
base, root = live
_two_rows(root)
page = browser.new_page(viewport={"width": 1400, "height": 900})
page.goto(f"{base}/", wait_until="networkidle")
page.mouse.move(0, 0)
row = page.locator('.desk-row[data-booth="loose"]')
pill = row.locator(".life").evaluate("e => [getComputedStyle(e).opacity, getComputedStyle(e).visibility, e.offsetWidth > 0]")
row.locator(".desk-acts .dl-link").focus()
page.wait_for_timeout(350)
focused = row.locator(".desk-acts").evaluate("a => [getComputedStyle(a).opacity, getComputedStyle(a).pointerEvents]")
page.close()
assert pill == ["1", "visible", True], pill
assert focused == ["1", "auto"], focused
def test_with_scripts_off_a_rows_controls_still_act(browser, live):
"""r2b INV-2, never exercised with JS actually off (heid code-review): the
hover reveal is CSS, and keep is a plain form."""
base, root = live
_two_rows(root)
ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1400, "height": 900})
page = ctx.new_page()
page.goto(f"{base}/", wait_until="networkidle")
row = page.locator('.desk-row[data-booth="loose"]')
row.hover()
page.wait_for_timeout(350)
with page.expect_navigation():
row.locator(".desk-acts form.keepit button").click()
ctx.close()
assert (root / "loose" / ".forever").exists()
def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live):
"""heid code-review (regin, seat-settled): the doc page carries the rules,
but no test turned Reveal all on and looked there."""
from booth.app import set_blurred
base, root = live
b = root / "g"
b.mkdir()
(b / "a.png").write_bytes(PNG)
(b / "n.md").write_text("# secret\n\nbody")
set_blurred(b, "a.png", True)
set_blurred(b, "n.md", True)
page = browser.new_page(viewport={"width": 1200, "height": 800})
page.goto(f"{base}/b/g/", wait_until="networkidle")
page.locator("[data-reveal-all]").click()
page.goto(f"{base}/b/g/view?f=n.md", wait_until="networkidle")
_settle(page)
got = [page.evaluate(_FILTER, "#docbody .markdown-body, #docbody .textview"),
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
<html>'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