Files
booth/tests/test_compare_browser.py
vh f8d136a521 fix(r3): fold heid's bug hunt — no link offers a pair that 404s, NUL booth names, a FIFO marker, encoded view-state names
Navigation was built from the review ring while the compare GET also demands
containment, so an outside symlink (which stays in the ring) was offered by
the strip, the steps, the review's Compare control and the flag landing, and
404ed on arrival. Every one is now built from the compare ring (the review
ring filtered by the same conjunction, _in_booth).

Two pre-existing gaps compare inherits, fixed at the source: resolve_booth
caught only OSError, so a NUL in the booth segment was a 500; record_view
opened its marker blocking, so a planted FIFO hung every look. Plus: the page
treats %73ide=a as side=a, and the subgrid engine floor is stated. Two
findings refuted (a chorded click mid-drag never fires pointerup, measured;
booth_items never yields an unquotable rel). r3.toml: 57 rows.
2026-09-24 14:39:06 -07:00

524 lines
26 KiB
Python

"""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