"""R3 — compare, in a real DOM. Contract: docs/contracts/r3_compare.contract.md. A TestClient can prove what the server answers; it cannot prove that two stages sit side by side, that a pan on one lands the other on the same crop, or that an in-place save keeps the active side. The harness is test_flow_browser's (a real uvicorn, a real offline Chromium), and like it this SKIPS, never fails, without a browser. """ import pathlib import sys sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from test_flow_browser import _png, browser, live # noqa: E402,F401 (fixtures) def _pics(root: pathlib.Path, pics: dict, name: str = "g") -> pathlib.Path: b = root / name b.mkdir() for rel, (w, h) in pics.items(): p = b / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(_png(w, h)) return b def _open(page, url): """Load a compare page and wait until every picture on a stage decoded.""" page.goto(url, wait_until="networkidle") page.wait_for_function("""[...document.querySelectorAll('.cmp-side .vstage img')] .every(i => i.complete && i.naturalWidth > 0)""") page.wait_for_timeout(150) _BOXES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => { const b = s.getBoundingClientRect(); return {side: s.dataset.side, l: b.left, r: b.right, t: b.top, b: b.bottom, h: b.height}; })""" def test_two_stages_side_by_side_wide_and_stacked_narrow(browser, live): """The tracer. Above 900px A and B share one row, A on the left, each exactly half the body — the SAME width, so equal pictures have equal ranges. At 900px and below (the review's break) they stack, A above B, each at most 45vh tall.""" base, root = live from booth.app import set_blurred b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)}) (b / "a.png.txt").write_text("a caption on A only, " * 8) set_blurred(b, "b.png", True) # the bar carries Reveal all too: its fullest got = {} for w, h in ((1440, 900), (901, 800), (900, 800), (390, 844)): page = browser.new_page(viewport={"width": w, "height": h}) _open(page, f"{base}/b/g/compare?a=a.png&b=b.png") got[w] = (page.evaluate(_BOXES), h, page.evaluate("document.documentElement.scrollWidth - document.documentElement.clientWidth"), # and nothing squeezed: every top-bar control one line, none # crushed narrower than its own content (a flex item shrinks # before it overflows — the Fit | 1:1 toggle went to 2px) page.evaluate("""() => { const c = [...document.querySelectorAll( '.vbar button, .vbar .vbtn, .vbar .vtoggle')].filter(e => e.offsetParent); return [Math.max(...c.map(e => e.getBoundingClientRect().height)), Math.max(...c.map(e => e.scrollWidth - e.clientWidth))]; }""")) page.close() (a, b), _, over, tallest = got[1440] assert (a["side"], b["side"]) == ("a", "b") assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], (a, b) assert a["r"] - a["l"] > 1440 * 0.4 and b["r"] - b["l"] > 1440 * 0.4, (a, b) # the SAME stage for both, or Fit draws one smaller: A's caption must not # take its height from A's stage alone assert abs(a["h"] - b["h"]) <= 1 and abs(a["b"] - b["b"]) <= 1, (a, b) # ...and the same width: a separator must not come out of one side alone assert abs((a["r"] - a["l"]) - (b["r"] - b["l"])) <= 0.5, (a, b) assert over <= 0 and tallest[0] <= 40 and tallest[1] <= 1, (over, tallest) (a, b), _, _, _ = got[901] assert abs(a["t"] - b["t"]) <= 1 and a["r"] <= b["l"], ("side by side at 901", a, b) (a, b), _, _, _ = got[900] assert a["b"] <= b["t"], ("stacked at 900", a, b) (a, b), vh, over, tallest = got[390] assert a["b"] <= b["t"], ("A above B", a, b) assert a["h"] <= 0.45 * vh + 1 and b["h"] <= 0.45 * vh + 1, (a, b) assert a["h"] > 100 and b["h"] > 100, (a, b) assert over <= 0, "the compare page scrolls sideways at phone width" assert tallest[0] <= 40, ("a top-bar control squeezed into a stack", tallest) assert tallest[1] <= 1, ("a top-bar control crushed narrower than its content", tallest) _IMGS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => { const b = i.getBoundingClientRect(); return [Math.round(b.width), Math.round(b.height), i.naturalWidth, i.naturalHeight]; })""" def test_one_mode_for_both_and_for_the_review(browser, live): """`Z` switches BOTH stages to 1:1 and stores it as the review's own preference: the review then opens in 1:1, and Z back is Fit for both.""" base, root = live _pics(root, {"a.png": (1600, 1200), "b.png": (1400, 1000)}) ctx = browser.new_context(viewport={"width": 1440, "height": 900}) page = ctx.new_page() _open(page, f"{base}/b/g/compare?a=a.png&b=b.png") fit = page.evaluate(_IMGS) page.keyboard.press("z") page.wait_for_timeout(150) one = page.evaluate(_IMGS) # both larger than their stages, so both offer the grab at once — not at # the next resize grab = page.evaluate("[...document.querySelectorAll('.cmp-side .vstage')].map(s => getComputedStyle(s).cursor)") stored = page.evaluate("localStorage.getItem('booth.fit')") pressed = page.locator("#btn-one").get_attribute("aria-pressed") page.goto(f"{base}/b/g/view?f=a.png", wait_until="networkidle") page.wait_for_function("document.getElementById('vimg').complete && document.getElementById('vimg').naturalWidth > 0") review_one = page.evaluate("""() => { const i = document.getElementById('vimg'), b = i.getBoundingClientRect(); return document.documentElement.classList.contains('stage-one') && Math.round(b.width) === i.naturalWidth && Math.round(b.height) === i.naturalHeight; }""") page.goto(f"{base}/b/g/compare?a=a.png&b=b.png", wait_until="networkidle") page.keyboard.press("Z") back = page.evaluate("[document.documentElement.classList.contains('stage-one'), localStorage.getItem('booth.fit')]") ctx.close() assert all([w, h] != [nw, nh] for w, h, nw, nh in fit), fit assert all([w, h] == [nw, nh] for w, h, nw, nh in one), one assert grab == ["grab", "grab"], grab assert stored == "one" and pressed == "true" and review_one is True, (stored, pressed, review_one) assert back == [False, None], back _SCROLLS = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => [s.scrollLeft, s.scrollTop])""" def _one_to_one(page, url): """Open a compare in 1:1 (the stored preference, applied before paint).""" page.add_init_script("try { localStorage.setItem('booth.fit', 'one'); } catch (e) {}") _open(page, url) def _center(page, side): box = page.locator(f'.cmp-side[data-side="{side}"] .vstage').bounding_box() return box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 def test_synced_pan_lands_on_the_same_crop(browser, live): """Two pictures of one size, larger than the stage, in 1:1. A drag on A of (+80, +60) scrolls BOTH by (-80, -60): the same pixels under the same point. A wheel on B moves A with it. After a second of idle neither has moved on its own (no sync loop).""" base, root = live _pics(root, {"a.png": (3000, 3000), "b.png": (3000, 3000)}) page = browser.new_page(viewport={"width": 1440, "height": 900}) _one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png") page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(500, 500)") page.wait_for_timeout(200) start = page.evaluate(_SCROLLS) cx, cy = _center(page, "a") page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up() page.wait_for_timeout(200) dragged = page.evaluate(_SCROLLS) bx, by = _center(page, "b") page.mouse.move(bx, by) page.mouse.wheel(0, 300) page.wait_for_timeout(600) wheeled = page.evaluate(_SCROLLS) page.wait_for_timeout(1000) idle = page.evaluate(_SCROLLS) page.close() assert start == [[500, 500], [500, 500]], start assert dragged == [[420, 440], [420, 440]], dragged assert wheeled[1][1] > 440 and wheeled[0] == wheeled[1], wheeled assert idle == wheeled, (wheeled, idle) _RANGES = """() => [...document.querySelectorAll('.cmp-side .vstage')].map(s => [s.scrollWidth - s.clientWidth, s.scrollHeight - s.clientHeight])""" # The first y near `from` on B whose trip B -> A -> B does not come back to # itself when each scroll lands on a whole pixel: where a sync that re-synced # its own echo would walk B off the spot it was put on. _LOSSY = """([from, rA, rB]) => { for (let y = from; y < from + 200; y++) if (Math.round(Math.round(y / rB * rA) / rA * rB) !== y) return y; return from; }""" def test_synced_pan_by_fraction_for_different_sizes(browser, live): """A 2000px and a 3000px picture. One at its middle puts the other at ITS middle; one at 25% of its range puts the other at 25% of ITS range — not at the same pixel offset. The equal-size test cannot see a fraction bug (equal overflow makes offsets and fractions coincide); this one can. And a side put somewhere STAYS there: the sync never echoes back and walks it.""" base, root = live _pics(root, {"a.png": (2000, 2000), "b.png": (3000, 3000)}) page = browser.new_page(viewport={"width": 1440, "height": 900}) _one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png") (rax, ray), (rbx, rby) = page.evaluate(_RANGES) a = '.cmp-side[data-side="a"] .vstage' b = '.cmp-side[data-side="b"] .vstage' page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [a, round(rax / 2), round(ray / 2)]) page.wait_for_timeout(250) middle = page.evaluate(_SCROLLS) y = page.evaluate(_LOSSY, [round(rby / 4), ray, rby]) x = page.evaluate(_LOSSY, [round(rbx / 4), rax, rbx]) page.evaluate("([s, x, y]) => document.querySelector(s).scrollTo(x, y)", [b, x, y]) page.wait_for_timeout(250) quarter = page.evaluate(_SCROLLS) page.wait_for_timeout(1000) idle = page.evaluate(_SCROLLS) page.close() assert rbx > rax > 0 and rby > ray > 0, (rax, ray, rbx, rby) assert abs(middle[1][0] - rbx / 2) <= 1 and abs(middle[1][1] - rby / 2) <= 1, (middle, rbx, rby) assert abs(quarter[0][0] / rax - x / rbx) * rax <= 1, (quarter, x) assert abs(quarter[0][1] / ray - y / rby) * ray <= 1, (quarter, y) assert quarter[0][0] < x - 100, "A must sit at ITS 25%, not at B's pixel offset" assert quarter[1] == [x, y] and idle == quarter, (x, y, quarter, idle) def test_an_axis_with_nothing_to_scroll_is_ignored(browser, live): """A side with nothing to scroll on an axis ignores that axis, each axis on its own. A is wide and short (it scrolls across only), B is large. B scrolled down stays down when A pans across: B's x follows, B's y is B's.""" base, root = live _pics(root, {"a.png": (3000, 200), "b.png": (3000, 3000)}) page = browser.new_page(viewport={"width": 1440, "height": 900}) _one_to_one(page, f"{base}/b/g/compare?a=a.png&b=b.png") (rax, ray), (rbx, rby) = page.evaluate(_RANGES) page.evaluate("document.querySelector('.cmp-side[data-side=\"b\"] .vstage').scrollTo(0, 500)") page.wait_for_timeout(250) down = page.evaluate(_SCROLLS) page.evaluate("document.querySelector('.cmp-side[data-side=\"a\"] .vstage').scrollTo(800, 0)") page.wait_for_timeout(250) across = page.evaluate(_SCROLLS) page.close() assert ray == 0 and rax > 0 and rby > 0, (rax, ray, rbx, rby) assert down == [[0, 0], [0, 500]], down assert across[0] == [800, 0], across assert abs(across[1][0] - 800 / rax * rbx) <= 1 and across[1][1] == 500, across def test_a_flags_A_in_place_and_the_stages_survive(browser, live): """`A` flags A in place: no navigation, A's control and label show the flag, B's do not, and both stages are the SAME nodes — a save swaps the regions and never a stage (a playing track would restart).""" from booth.marks import marks_for base, root = live b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)}) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=a.png&b=b.png") page.evaluate("""() => { window.__noReload = 1; window.__stages = [...document.querySelectorAll('.cmp-side .vstage')]; }""") page.keyboard.press("a") page.wait_for_selector("#cmp-flag-a.is-flagged", timeout=10000) got = page.evaluate("""() => ({ reload: window.__noReload !== 1, same: [...document.querySelectorAll('.cmp-side .vstage')].every((s, i) => s === window.__stages[i]), a: document.querySelector('[data-region="label-a"]').textContent, b: document.querySelector('[data-region="label-b"]').textContent, bflag: document.getElementById('cmp-flag-b').classList.contains('is-flagged'), strip: [...document.querySelectorAll('.film-f.is-flagged')].map(f => f.dataset.rel), })""") flagged = [m.target for m in marks_for(b) if m.shape == "flag"] # the save REPLACED the buttons: each key must find the fresh one page.keyboard.press("b") page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000) page.keyboard.press("a") page.wait_for_selector("#cmp-flag-a:not(.is-flagged)", timeout=10000) after = sorted(m.target for m in marks_for(b) if m.shape == "flag") reloaded = page.evaluate("window.__noReload !== 1") page.close() assert not got["reload"] and got["same"], got assert "flagged" in got["a"] and "flagged" not in got["b"] and not got["bflag"], got assert got["strip"] == ["a.png"], got assert flagged == ["a.png"] and after == ["b.png"] and not reloaded, (flagged, after, reloaded) def _bakeoff(root: pathlib.Path) -> pathlib.Path: """sindra-bakeoff's shape: two lanes, m and r, the same scenes and seeds, laid out as two parallel runs in sorted order — no pairing rule needed.""" scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42") return _pics(root, {f"{lane}-{i}-{sc}.png": (400, 300) for lane in ("m", "r") for i, sc in enumerate(scenes, 1)}) def _pair(page) -> tuple[str, str]: from urllib.parse import parse_qs, urlsplit q = parse_qs(urlsplit(page.url).query) return q["a"][0], q["b"][0] def _press_and_wait(page, key): with page.expect_navigation(wait_until="networkidle"): page.keyboard.press(key) def test_linked_arrow_walks_a_bakeoff(browser, live): """m#1 against r#1, then `→` three times: every pair is the same scene and seed in the two lanes.""" base, root = live _bakeoff(root) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png") pairs = [_pair(page)] for _ in range(3): _press_and_wait(page, "ArrowRight") pairs.append(_pair(page)) page.close() # every pair, in order: no pair skipped and none repeated scenes = ("dock-s11", "forge-s23", "marsh-s37", "tower-s42") assert pairs == [(f"m-{i}-{sc}.png", f"r-{i}-{sc}.png") for i, sc in enumerate(scenes, 1)], pairs def test_unlinked_moves_only_the_active_side_and_the_strip_picks_it(browser, live): """`L` unlinks: `→` moves only B, and a SECOND `→` still moves only B (the state survived the navigation). A strip click replaces the active side. `X` swaps the active side, the reticle follows, and it survives a step.""" base, root = live _bakeoff(root) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png") page.keyboard.press("l") linked = page.locator("#cmp-link").get_attribute("aria-pressed") _press_and_wait(page, "ArrowRight") one = _pair(page) _press_and_wait(page, "ArrowRight") two = _pair(page) _press_and_wait(page, "ArrowLeft") left = _pair(page) _press_and_wait(page, "ArrowRight") with page.expect_navigation(wait_until="networkidle"): page.locator('.film-f[data-rel="m-3-marsh-s37.png"]').click() picked = _pair(page) page.keyboard.press("x") active = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side, [...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel)]""") _press_and_wait(page, "ArrowRight") stepped = _pair(page) after = page.evaluate("""() => [document.querySelector('.cmp-side.is-active').dataset.side, getComputedStyle(document.querySelector('.cmp-side.is-active > .cmp-stagewrap'), '::after').backgroundImage !== 'none', getComputedStyle(document.querySelector('.cmp-side:not(.is-active) > .cmp-stagewrap'), '::after').backgroundImage !== 'none']""") page.close() assert linked == "false", linked assert one == ("m-1-dock-s11.png", "r-2-forge-s23.png"), one assert two == ("m-1-dock-s11.png", "r-3-marsh-s37.png"), two assert left == one, ("← moves only the active side back", left) assert picked == ("m-1-dock-s11.png", "m-3-marsh-s37.png"), picked assert active == ["a", ["m-1-dock-s11.png"]], active assert stepped == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), stepped assert after == ["a", True, False], after _FILTERS = """() => [...document.querySelectorAll('.cmp-side .vstage img')].map(i => getComputedStyle(i).filter)""" _REVEALS = """() => [...document.querySelectorAll('.cmp-reveal')].map(b => getComputedStyle(b).display !== 'none')""" def test_blur_is_honest_on_both_sides(browser, live): """A blurred side IS blurred — the computed filter, not just a class. Its own reveal lifts it and leaves the other side blurred. Reveal all lifts both, and stands both per-side reveals down.""" from booth.app import set_blurred base, root = live b = _pics(root, {"a.png": (800, 600), "b.png": (800, 600)}) set_blurred(b, "a.png", True) set_blurred(b, "b.png", True) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=a.png&b=b.png") before = page.evaluate(_FILTERS) shown = page.evaluate(_REVEALS) page.locator('.cmp-reveal[data-side="b"]').click() page.wait_for_timeout(400) # the filter transition own = page.evaluate(_FILTERS) page.locator('.cmp-reveal[data-side="b"]').click() page.locator('.cmp-reveal[data-side="a"]').click() page.wait_for_timeout(400) own_a = page.evaluate(_FILTERS) page.locator('.cmp-reveal[data-side="a"]').click() page.locator("[data-reveal-all]").click() page.wait_for_timeout(400) everything = page.evaluate(_FILTERS) stood_down = page.evaluate(_REVEALS) page.close() assert all("blur(" in f for f in before), before assert shown == [True, True], shown assert "blur(" in own[0] and "blur(" not in own[1], own assert "blur(" not in own_a[0] and "blur(" in own_a[1], own_a assert all("blur(" not in f for f in everything), everything assert stood_down == [False, False], stood_down def test_a_save_keeps_the_active_side(browser, live): """Make A active (a press on its stage), then flag B in place. The save swaps the strip and the labels, and A is STILL the active side — on its stage and on the strip — and a strip click after the swap replaces A.""" base, root = live _bakeoff(root) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png") ax, ay = _center(page, "a") page.mouse.click(ax, ay) page.evaluate("window.__noReload = 1") page.keyboard.press("b") page.wait_for_selector("#cmp-flag-b.is-flagged", timeout=10000) kept = page.evaluate("""() => ({ reload: window.__noReload !== 1, side: document.querySelector('.cmp-side.is-active').dataset.side, strip: [...document.querySelectorAll('.film-f.is-active')].map(f => f.dataset.rel), url: location.search, })""") with page.expect_navigation(wait_until="networkidle"): page.locator('.film-f[data-rel="m-2-forge-s23.png"]').click() picked = _pair(page) side = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side") # the active side lives in the URL: `X` rewrites it in place, so a reload # shows the side that was active, not the default page.keyboard.press("x") page.reload(wait_until="networkidle") reloaded = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side") page.close() assert reloaded == "b", reloaded assert not kept["reload"] and kept["side"] == "a", kept assert kept["strip"] == ["m-1-dock-s11.png"] and "side=a" in kept["url"], kept assert picked == ("m-2-forge-s23.png", "r-1-dock-s11.png") and side == "a", (picked, side) def test_an_encoded_view_state_name_is_still_view_state(browser, live): """`%73ide=a` IS `side=a` to the server, so the page must treat it as view state too: after X, a reload shows the side X chose, not the stale one the encoded parameter still named (heid bug hunt, hulda).""" base, root = live _bakeoff(root) page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png&%73ide=a") first = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side") page.keyboard.press("x") page.reload(wait_until="networkidle") after = page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side") page.close() assert (first, after) == ("a", "b"), (first, after) def test_without_js_every_judgment_and_step_still_works(browser, live): """INV-4. Scripts off: the pair renders in two Fit stages, the step and strip links navigate, both flag forms are there and a flag lands back on the same pair. Nothing judgment-bearing hides behind a script.""" from booth.marks import marks_for base, root = live b = _bakeoff(root) ctx = browser.new_context(java_script_enabled=False, viewport={"width": 1440, "height": 900}) page = ctx.new_page() page.goto(f"{base}/b/g/compare?a=m-1-dock-s11.png&b=r-1-dock-s11.png", wait_until="networkidle") fit = page.evaluate("""() => document.documentElement.classList.contains('stage-one') || [...document.querySelectorAll('.cmp-side .vstage img')].some(i => Math.round(i.getBoundingClientRect().width) === i.naturalWidth)""") hidden = [page.locator(s).is_visible() for s in ("#cmp-link", "#vtoggle")] forms = page.locator(".cmp-flag form").count() page.locator('a[data-step="both-next"]').click() page.wait_for_load_state("networkidle") both = _pair(page) page.locator('a[data-step="a-prev"]').click() page.wait_for_load_state("networkidle") a_back = _pair(page) page.locator('.film-f[data-rel="m-4-tower-s42.png"]').click() page.wait_for_load_state("networkidle") strip = _pair(page) page.locator("#cmp-flag-b").click() page.wait_for_load_state("networkidle") landed = _pair(page) shows = page.locator("#cmp-flag-b").get_attribute("class") ctx.close() assert fit is False and hidden == [False, False] and forms == 2, (fit, hidden, forms) assert both == ("m-2-forge-s23.png", "r-2-forge-s23.png"), both assert a_back == ("m-1-dock-s11.png", "r-2-forge-s23.png"), a_back assert strip == ("m-1-dock-s11.png", "m-4-tower-s42.png"), strip assert landed == strip and "is-flagged" in shows, (landed, shows) assert [m.target for m in marks_for(b) if m.shape == "flag"] == ["m-4-tower-s42.png"] def test_the_keys_keep_the_reviews_guards_and_c_toggles_the_view(browser, live): """C3: a held modifier makes every key inert; Space on a focused control presses it and never steps, and from nowhere in particular it steps (Shift+Space back). `C` in the review opens compare against the next item; `Esc` (or `C`) in compare returns to the review of A.""" base, root = live _bakeoff(root) v = _pics(root, {"a.png": (400, 300)}, name="v") (v / "b.webm").write_bytes(b"\x1aE\xdf\xa3") page = browser.new_page(viewport={"width": 1440, "height": 900}) _open(page, f"{base}/b/g/compare?a=m-2-forge-s23.png&b=r-2-forge-s23.png") start = page.url for key in ("Control+ArrowRight", "Alt+ArrowRight", "Meta+ArrowRight", "Control+x", "Alt+l"): page.keyboard.press(key) page.wait_for_timeout(300) inert = (page.url == start, page.evaluate("document.querySelector('.cmp-side.is-active').dataset.side"), page.locator("#cmp-link").get_attribute("aria-pressed")) page.locator("#cmp-link").focus() page.keyboard.press(" ") page.wait_for_timeout(300) pressed = (page.url.split("?")[1], page.locator("#cmp-link").get_attribute("aria-pressed")) page.locator("#cmp-link").press(" ") # linked again page.evaluate("document.activeElement.blur()") # ...and never from a player on either stage: Space is the player's page.goto(f"{base}/b/v/compare?a=a.png&b=b.webm", wait_until="networkidle") page.locator(".cmp-media").focus() page.keyboard.press(" ") page.wait_for_timeout(300) player = page.url.endswith("/b/v/compare?a=a.png&b=b.webm") page.goto(start, wait_until="networkidle") _press_and_wait(page, " ") fwd = _pair(page) _press_and_wait(page, "Shift+ ") back = _pair(page) _press_and_wait(page, "Escape") esc = page.url _press_and_wait(page, "c") c_review = _pair(page) _press_and_wait(page, "C") c_back = page.url page.close() assert inert == (True, "b", "true"), inert assert player, "Space on a focused player stepped the pair" assert pressed == ("a=m-2-forge-s23.png&b=r-2-forge-s23.png&link=0", "false"), pressed assert fwd == ("m-3-marsh-s37.png", "r-3-marsh-s37.png") and back == ("m-2-forge-s23.png", "r-2-forge-s23.png"), (fwd, back) assert esc.endswith("/b/g/view?f=m-2-forge-s23.png"), esc assert c_review == ("m-2-forge-s23.png", "m-3-marsh-s37.png"), c_review assert c_back.endswith("/b/g/view?f=m-2-forge-s23.png"), c_back