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.
524 lines
26 KiB
Python
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
|