feat(review): the review stage fills, its arrows sit at the picture, 1:1 pans (r2c)
The operator: "fit and 1:1 modes as well as moving the forward and back arrows closer to the edge of the image ... mouse click and pan for 1:1 mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit may enlarge." - Fit: the picture's box is the stage's inner box, and object-fit: contain draws it whole at the largest size that fits, up or down, never cropped. It works with or without JS. 1:1 is natural pixels. - The Fit | 1:1 toggle shows for every picture; the per-picture hide is gone. It stays hidden without JS. - The mode persists as `stage-one` on <html>, set by the head script before the stage exists, so a 1:1 reel never paints a stage in Fit. Anything stored but "one" reads as Fit. Storage never raises. - The arrows sit wholly outside the DRAWN picture (near edge 8px), clamped 8px inside the stage. They sit over the picture only when it spans the stage, and never over the rail. They are re-placed on load, resize, mode switch and 1:1 scroll, and keep their CSS spot until the drawn box is known. - 1:1 drag-to-pan when the picture overflows either axis: the picture follows the pointer, a 4px threshold, pointer capture, grab/grabbing. The picture is draggable=false. The stage's reveal button moves out of the scrolled content to sit over the stage (a pan carried it off), so no control is a pan source. Contract docs/contracts/r2c_review_stage.contract.md (heid contract panel 4/4 folded; it changed the no-flash mechanism). Declared test changes: the Nyx stage-edge arrow test is replaced; the stage class and the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803 passed.
This commit is contained in:
+213
-16
@@ -394,22 +394,6 @@ def test_the_standalone_marks_page_updates_in_place(browser, live):
|
||||
assert same
|
||||
|
||||
|
||||
def test_the_next_arrow_clears_the_rail_only_beside_it(browser, live):
|
||||
"""Nyx: view.html's bare `.vnext{right:360px}` came later in the page than
|
||||
base.html's narrow override and won it, parking the arrow 360px in from
|
||||
the edge of a phone. Wide: it clears the rail. Narrow: it sits at the edge."""
|
||||
base, root = live
|
||||
_set(root, 3)
|
||||
rights = {}
|
||||
for w in (1400, 390):
|
||||
page = browser.new_page(viewport={"width": w, "height": 900})
|
||||
page.goto(f"{base}/b/g/view?f=01.png", wait_until="networkidle")
|
||||
rights[w] = page.evaluate(
|
||||
"getComputedStyle(document.querySelector('.vnav.vnext')).right")
|
||||
page.close()
|
||||
assert rights == {1400: "360px", 390: "0px"}
|
||||
|
||||
|
||||
def test_the_desk_never_scrolls_sideways_at_any_width(browser, tmp_path):
|
||||
"""The row-controls change made a Desk row a flex container, and a long
|
||||
provenance line (nowrap, ellipsised) then set the Desk column's MINIMUM
|
||||
@@ -1083,3 +1067,216 @@ def test_reveal_all_lifts_the_doc_page_it_reaches(browser, live):
|
||||
page.evaluate("getComputedStyle(document.getElementById('docreveal')).display")]
|
||||
page.close()
|
||||
assert got == ["none", "none"], got
|
||||
|
||||
|
||||
# ---- r2c: the review stage ---------------------------------------------------
|
||||
|
||||
|
||||
def _png(w: int, h: int, rgb=(90, 120, 160)) -> bytes:
|
||||
"""A real, decodable PNG of a given size (no Pillow needed): the stage's
|
||||
geometry depends on NATURAL sizes, which the 8-byte stub has none of."""
|
||||
import struct
|
||||
import zlib
|
||||
raw = b"".join(b"\x00" + bytes(rgb) * w for _ in range(h))
|
||||
def chunk(t, d):
|
||||
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
def _stage_set(root: pathlib.Path, pics: dict) -> pathlib.Path:
|
||||
b = root / "g"
|
||||
b.mkdir()
|
||||
for name, (w, h) in pics.items():
|
||||
(b / name).write_bytes(_png(w, h))
|
||||
return b
|
||||
|
||||
|
||||
_GEOM = """() => {
|
||||
const st = document.getElementById('vstage'), img = document.getElementById('vimg');
|
||||
const r = e => { const b = e.getBoundingClientRect(); return {l: b.left, r: b.right, t: b.top, b: b.bottom, w: b.width, h: b.height}; };
|
||||
const cs = getComputedStyle(st);
|
||||
const box = r(img), nw = img.naturalWidth, nh = img.naturalHeight;
|
||||
const k = Math.min(box.w / nw, box.h / nh), dw = nw * k;
|
||||
return {stage: r(st), inner: {w: st.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight),
|
||||
h: st.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)},
|
||||
img: box, nat: [nw, nh], fit: getComputedStyle(img).objectFit, scale: k,
|
||||
drawn: {l: box.l + (box.w - dw) / 2, r: box.l + (box.w + dw) / 2},
|
||||
prev: document.querySelector('.vnav.vprev') && r(document.querySelector('.vnav.vprev')),
|
||||
next: document.querySelector('.vnav.vnext') && r(document.querySelector('.vnav.vnext')),
|
||||
rail: r(document.getElementById('rail'))};
|
||||
}"""
|
||||
|
||||
|
||||
def _load(page, url):
|
||||
page.goto(url, wait_until="networkidle")
|
||||
page.wait_for_function("document.getElementById('vimg').complete && document.getElementById('vimg').naturalWidth > 0")
|
||||
page.wait_for_timeout(150)
|
||||
|
||||
|
||||
def test_fit_fills_the_stage_up_or_down(browser, live):
|
||||
"""r2c S1 (operator ruling: 'Fit may enlarge'). In Fit the picture's box IS
|
||||
the stage's inner box and `object-fit: contain` draws the whole picture at
|
||||
min(W/w, H/h) — UP for a small one, down for a large one, never cropped.
|
||||
In 1:1 it is its natural size."""
|
||||
base, root = live
|
||||
_stage_set(root, {"small.png": (200, 100), "big.png": (3000, 1500)})
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
got = {}
|
||||
for name in ("small.png", "big.png"):
|
||||
_load(page, f"{base}/b/g/view?f={name}")
|
||||
fit = page.evaluate(_GEOM)
|
||||
page.locator("#btn-one").click()
|
||||
page.wait_for_timeout(150)
|
||||
one = page.evaluate(_GEOM)
|
||||
page.locator("#btn-fit").click()
|
||||
got[name] = (fit, one)
|
||||
page.close()
|
||||
for name, (fit, one) in got.items():
|
||||
assert abs(fit["img"]["w"] - fit["inner"]["w"]) <= 1 and abs(fit["img"]["h"] - fit["inner"]["h"]) <= 1, (name, fit)
|
||||
assert fit["fit"] == "contain", (name, fit["fit"])
|
||||
assert [round(one["img"]["w"]), round(one["img"]["h"])] == one["nat"], (name, one["img"], one["nat"])
|
||||
assert got["small.png"][0]["scale"] > 1.5 # enlarged
|
||||
assert got["big.png"][0]["scale"] < 1 # reduced
|
||||
|
||||
|
||||
def test_the_toggle_shows_for_every_picture_and_never_without_js(browser, live):
|
||||
"""r2c S2: the per-picture hide is gone — a picture that fits at natural
|
||||
size still gets Fit | 1:1. Audio gets none. Without JS it never shows."""
|
||||
base, root = live
|
||||
b = _stage_set(root, {"small.png": (200, 100)})
|
||||
(b / "t.mp3").write_bytes(b"ID3")
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
_load(page, f"{base}/b/g/view?f=small.png")
|
||||
pic = page.locator("#vtoggle").is_visible()
|
||||
page.goto(f"{base}/b/g/view?f=t.mp3", wait_until="networkidle")
|
||||
audio = page.locator("#vtoggle").count()
|
||||
page.close()
|
||||
ctx = browser.new_context(java_script_enabled=False)
|
||||
nojs_page = ctx.new_page()
|
||||
nojs_page.goto(f"{base}/b/g/view?f=small.png", wait_until="networkidle")
|
||||
nojs = nojs_page.locator("#vtoggle").is_visible()
|
||||
ctx.close()
|
||||
assert pic and audio == 0 and not nojs, (pic, audio, nojs)
|
||||
|
||||
|
||||
_STAGE_WATCH = """
|
||||
window.__stageOne = null;
|
||||
new MutationObserver(function (m, obs) {
|
||||
if (document.getElementById('vstage')) {
|
||||
window.__stageOne = document.documentElement.classList.contains('stage-one');
|
||||
obs.disconnect();
|
||||
}
|
||||
}).observe(document, {childList: true, subtree: true});
|
||||
"""
|
||||
|
||||
|
||||
def test_the_mode_persists_across_prev_next_and_never_flashes(browser, live):
|
||||
"""r2c S2: 1:1 chosen on one picture holds on the next, and it is in force
|
||||
BEFORE the stage exists — an observer set before any page script records
|
||||
<html>'s class as the parser inserts the stage. A stray stored value reads
|
||||
as Fit; a storage write that throws still applies the click."""
|
||||
base, root = live
|
||||
_stage_set(root, {"a.png": (1600, 1200), "b.png": (1600, 1200)})
|
||||
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
page = ctx.new_page()
|
||||
page.add_init_script(_STAGE_WATCH)
|
||||
_load(page, f"{base}/b/g/view?f=a.png")
|
||||
first = page.evaluate("window.__stageOne")
|
||||
page.locator("#btn-one").click()
|
||||
stored = page.evaluate("localStorage.getItem('booth.fit')")
|
||||
page.keyboard.press("ArrowRight")
|
||||
page.wait_for_url("**/view?f=b.png")
|
||||
_load(page, page.url)
|
||||
at_parse = page.evaluate("window.__stageOne")
|
||||
pressed = page.locator("#btn-one").get_attribute("aria-pressed")
|
||||
page.evaluate("localStorage.setItem('booth.fit', 'zoom')")
|
||||
page.reload(wait_until="networkidle")
|
||||
stray = page.evaluate("window.__stageOne")
|
||||
ctx.close()
|
||||
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
page = ctx.new_page()
|
||||
page.add_init_script("Storage.prototype.setItem = function () { throw new Error('quota'); };")
|
||||
_load(page, f"{base}/b/g/view?f=a.png")
|
||||
page.locator("#btn-one").click()
|
||||
applied = page.evaluate("document.documentElement.classList.contains('stage-one')")
|
||||
ctx.close()
|
||||
assert first is False and stored == "one", (first, stored)
|
||||
assert at_parse is True and pressed == "true", (at_parse, pressed)
|
||||
assert stray is False and applied is True, (stray, applied)
|
||||
|
||||
|
||||
def test_the_arrows_sit_just_outside_the_picture_and_clamp_to_the_stage(browser, live):
|
||||
"""r2c S3 (operator: 'arrows closer to the edge of the image instead of out
|
||||
at the edges unless the image spans the entire width'). A portrait whose
|
||||
NATURAL width exceeds the stage but which is DRAWN narrower (height-bound in
|
||||
Fit): each arrow wholly outside the drawn picture, near edge 8px from it.
|
||||
A landscape drawn as wide as the stage: arrows at the stage's edges, over
|
||||
the picture. Never over the rail; after a resize they follow."""
|
||||
base, root = live
|
||||
_stage_set(root, {"a-tall.png": (1400, 2800), "b-wide.png": (3000, 900)})
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
_load(page, f"{base}/b/g/view?f=a-tall.png")
|
||||
tall = page.evaluate(_GEOM)
|
||||
_load(page, f"{base}/b/g/view?f=b-wide.png")
|
||||
wide = page.evaluate(_GEOM)
|
||||
page.set_viewport_size({"width": 1200, "height": 800})
|
||||
page.goto(f"{base}/b/g/view?f=a-tall.png", wait_until="networkidle")
|
||||
_load(page, page.url)
|
||||
page.set_viewport_size({"width": 1300, "height": 850})
|
||||
page.wait_for_timeout(300)
|
||||
resized = page.evaluate(_GEOM)
|
||||
page.close()
|
||||
assert tall["nat"][0] > tall["stage"]["w"], "the fixture must be naturally wider than the stage"
|
||||
for g in (tall, resized):
|
||||
assert abs((g["drawn"]["l"] - 8) - g["prev"]["r"]) <= 2, (g["drawn"], g["prev"])
|
||||
assert abs(g["next"]["l"] - (g["drawn"]["r"] + 8)) <= 2, (g["drawn"], g["next"])
|
||||
for g in (tall, wide, resized):
|
||||
assert g["prev"]["l"] >= g["stage"]["l"] and g["next"]["r"] <= g["stage"]["r"], g
|
||||
assert g["next"]["r"] <= g["rail"]["l"], "an arrow over the rail"
|
||||
assert abs(wide["prev"]["l"] - (wide["stage"]["l"] + 8)) <= 2 and abs(wide["next"]["r"] - (wide["stage"]["r"] - 8)) <= 2, wide
|
||||
|
||||
|
||||
def test_in_one_to_one_a_drag_pans_and_the_picture_cannot_be_dragged_away(browser, live):
|
||||
"""r2c S4 (operator: 'mouse click and pan for 1:1 mode ... defeat drag drop
|
||||
of image'). The picture follows the pointer: a drag of (+80, +60) scrolls
|
||||
the stage by (-80, -60). A 2px press pans nothing; a press on the stage's
|
||||
own reveal button reveals and does not pan; the picture is not draggable;
|
||||
in Fit a drag scrolls nothing."""
|
||||
from booth.app import set_blurred
|
||||
base, root = live
|
||||
b = _stage_set(root, {"huge.png": (3000, 3000)})
|
||||
set_blurred(b, "huge.png", True)
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||
_load(page, f"{base}/b/g/view?f=huge.png")
|
||||
page.evaluate("document.getElementById('vimg').closest('.vstage').scrollTo(0, 0)")
|
||||
fit_before = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
|
||||
st = page.locator("#vstage").bounding_box()
|
||||
cx, cy = st["x"] + st["width"] / 2, st["y"] + st["height"] / 2
|
||||
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
|
||||
fit_after = page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
|
||||
page.locator("#btn-one").click()
|
||||
page.evaluate("document.getElementById('vstage').scrollTo(500, 500)")
|
||||
scroll = lambda: page.evaluate("[document.getElementById('vstage').scrollLeft, document.getElementById('vstage').scrollTop]")
|
||||
cursor = page.evaluate("getComputedStyle(document.getElementById('vstage')).cursor")
|
||||
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 80, cy + 60, steps=6); page.mouse.up()
|
||||
panned = scroll()
|
||||
page.mouse.move(cx, cy); page.mouse.down(); page.mouse.move(cx + 2, cy + 1, steps=2); page.mouse.up()
|
||||
jitter = scroll()
|
||||
# the stage's reveal sits over the stage, not in its scrolled content: at any
|
||||
# scroll it is in view, a click on it reveals, and a drag from it pans nothing
|
||||
btn = page.locator("#vreveal").bounding_box()
|
||||
bx, by = btn["x"] + btn["width"] / 2, btn["y"] + btn["height"] / 2
|
||||
page.mouse.move(bx, by); page.mouse.down(); page.mouse.move(bx + 80, by + 60, steps=6); page.mouse.up()
|
||||
dragged_from_button = scroll()
|
||||
page.locator("#vreveal").click()
|
||||
revealed = page.evaluate("document.getElementById('vstage').classList.contains('revealed')")
|
||||
after_button = scroll()
|
||||
draggable = page.evaluate("document.getElementById('vimg').draggable")
|
||||
page.close()
|
||||
assert fit_before == fit_after, (fit_before, fit_after)
|
||||
assert cursor == "grab", cursor
|
||||
assert panned == [420, 440], panned
|
||||
assert jitter == [420, 440] and dragged_from_button == [420, 440], (jitter, dragged_from_button)
|
||||
assert revealed and after_button == [420, 440], (revealed, after_button)
|
||||
assert draggable is False
|
||||
|
||||
Reference in New Issue
Block a user