The one drift: the separator was a border on B, making B's stage 1px narrower than A's; it is now a 1px column gap, so the stages are the same size to the pixel. Tests now read what the contract promises instead of a proxy: the strip's ring order, the full bakeoff sequence, 1:1 and Fit by geometry, the 900px break from both sides, A wrapping, each form naming its own item, a sibling-prefix symlink, both reveals, the strip's flag, the back arrow unlinked, a one-axis picture, a focused player, two videos with no toggle. The contract names .cmp-cap, a press on a stage, INV-4's URL-driven picker and the redirect branch's isinstance check. r3.toml gains ten rows.
508 lines
25 KiB
Python
508 lines
25 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_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
|