- 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.
1502 lines
74 KiB
Python
1502 lines
74 KiB
Python
"""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 = "safegnp.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
|