feat(r3): compare — two picked rels side by side, linked stepping, synced pan, flag the winner

GET /b/{name}/compare with the conjunction 404 (containment AND the review
ring), both sides recorded as seen, view state (side, link) mapped from a
closed set onto every link, side-keyed regions, and back=compare in
_mark_redirect. compare.html: two stages sharing one set of rows, the strip
as picker (the side active now), linked and per-side stepping, X/L/Z/A/B/C
keys under the review's guards, synced pan by fraction with an echo guard,
per-side blur reveals, JS-off parity.

The stage machinery moves out of view.html into _stage_js.html
(BoothMode.bind, BoothStage.attach), shared by the review and compare. The
review gains a Compare control and a C key. At phone width a full top bar
wraps.

Tables: r2c's 15 stage rows re-pointed to _stage_js.html; r2b's phone
top-bar row re-anchored (the wrap made it vacuous alone); new r3.toml. The
contract records the wrap, equal stages and C on the compare page.
This commit is contained in:
vh
2026-09-24 13:26:11 -07:00
parent 5d785fe3c4
commit 8c7fe77841
11 changed files with 1841 additions and 111 deletions
+451
View File
@@ -0,0 +1,451 @@
"""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. At 1440 A and B share one row, A on the left, each about
half the body. At 390 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), (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)
assert over <= 0 and tallest[0] <= 40 and tallest[1] <= 1, (over, tallest)
(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")
review_one = page.evaluate("document.documentElement.classList.contains('stage-one')")
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_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'),
})""")
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 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()
assert [(a[2:], b[2:]) for a, b in pairs] == [(a[2:], a[2:]) for a, _ in pairs], pairs
assert [a[0] + b[0] for a, b in pairs] == ["mr"] * 4, pairs
assert pairs[-1] == ("m-4-tower-s42.png", "r-4-tower-s42.png"), 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)
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 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("[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 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')")
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)
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()")
_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 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