Let a swipe count while the viewport is still moving
A viewport change opens a window in which the feed holds the reader's video in place, because the scroll-snap container re-anchors on positions measured against the height from before it and must not be allowed to flip the video. The window answered every scroll it saw with a realign. On a phone that is the worst moment to do it: the chrome sliding out of the way is caused by the swipe, so the window stood open at exactly the moment the reader was swiping. The reel moved under the finger, far enough to show the top of the next video at the bottom of the screen, and was then pulled back to where it started -- a second later, once the settle window ran out. A re-anchor has nothing behind it and a swipe has a finger or a wheel, so record the input and let a scroll that follows one close the window instead of being reverted into it. Closing it that way skips the realign, or the close would undo the swipe that caused it. Only input that means to scroll counts -- a finger that has moved rather than one that has merely landed, since a tap must not sign for the re-anchor after it. Telling a turn apart from the chrome moving was reading the orientation flip alone, which a few pixels of height cross whenever the viewport is near enough to square. That pinned the slides and turned snapping off for a second over a height change, which is the one thing it is there to tell a turn apart from; a turn swaps the long side, and swapping it moves the width too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
216
tests/smoke_feed_swipe.py
Normal file
216
tests/smoke_feed_swipe.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A swipe counts even when the viewport just moved.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_feed_swipe.py
|
||||
|
||||
Both the status and the listing are fixtures, so the reel is long enough to
|
||||
swipe through and the test never waits on a real source.
|
||||
|
||||
A viewport change opens a realign window in which the feed holds the reader's
|
||||
video in place, because the scroll-snap container re-anchors on positions
|
||||
measured against the old height and must not be allowed to flip the video (see
|
||||
smoke_rotate). The window used to revert *every* scroll it saw, and on a phone
|
||||
that is the worst possible moment to do it: the chrome sliding out of the way
|
||||
is caused by the swipe, so the window was open exactly when the reader was
|
||||
swiping. The reel moved under the finger -- far enough to show the next video
|
||||
coming in at the bottom -- and was then pulled back to where it started.
|
||||
|
||||
So the two halves have to be told apart, and the test asks for both: a scroll
|
||||
with real input behind it is the reader's and is kept, a bare re-anchor is not
|
||||
and is still undone.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://fixture.test"
|
||||
CHANNEL = "clips"
|
||||
PORTRAIT = {"width": 430, "height": 930}
|
||||
# Chrome sliding away: height only, so this is not a turn -- it is the resize
|
||||
# that used to eat the swipe that caused it.
|
||||
SHORTER = {"width": 430, "height": 860}
|
||||
# Near enough to square that a little height crosses it. Reading the `w > h`
|
||||
# flip on its own called that a rotation, pinned the slides and turned
|
||||
# snapping off for about a second.
|
||||
SQUARE = {"width": 800, "height": 810}
|
||||
SQUARE_SHORT = {"width": 800, "height": 790}
|
||||
|
||||
STATUS = {
|
||||
"online": True,
|
||||
"channels": [{
|
||||
"id": CHANNEL,
|
||||
"name": "Fixture Clips",
|
||||
"description": "A channel that only exists for this test.",
|
||||
"options": [],
|
||||
}],
|
||||
}
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
WHERE = """() => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
const root = document.getElementById('feed-view');
|
||||
const active = document.querySelector('.feed-slide.is-active');
|
||||
const h = scroller.clientHeight;
|
||||
return {
|
||||
index: App.state.feedActiveIndex,
|
||||
videoId: App.state.feedActiveVideoId,
|
||||
scrollTop: Math.round(scroller.scrollTop),
|
||||
expected: App.state.feedActiveIndex * h,
|
||||
height: h,
|
||||
activeTop: active ? Math.round(active.getBoundingClientRect().top) : None_,
|
||||
turning: root.classList.contains('is-turning'),
|
||||
snap: getComputedStyle(scroller).scrollSnapType,
|
||||
offHeight: Array.from(document.querySelectorAll('.feed-slide'))
|
||||
.map((s) => s.getBoundingClientRect().height)
|
||||
.filter((sh) => Math.abs(sh - h) > 1),
|
||||
};
|
||||
}""".replace("None_", "null")
|
||||
|
||||
GO = """(step) => {
|
||||
const scroller = document.getElementById('feed-scroll');
|
||||
scroller.scrollTop = step * scroller.clientHeight;
|
||||
return App.state.feedActiveIndex;
|
||||
}"""
|
||||
|
||||
# The container's own re-anchor: a scroll position and nothing behind it.
|
||||
RESNAP = """(top) => {
|
||||
document.getElementById('feed-scroll').scrollTop = top;
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def items():
|
||||
return [{"id": f"vid-{i}", "title": f"clip {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
|
||||
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
|
||||
|
||||
|
||||
def swipe_to(page, step):
|
||||
for n in range(1, step + 1):
|
||||
page.evaluate(GO, n)
|
||||
page.wait_for_timeout(250)
|
||||
page.wait_for_timeout(400)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport=PORTRAIT)
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
page.route("**/api/status", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json", body=json.dumps(STATUS)))
|
||||
page.route("**/api/videos", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
|
||||
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
|
||||
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.wait_for_timeout(500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
print("\nseven videos into the reel")
|
||||
swipe_to(page, 7)
|
||||
start = page.evaluate(WHERE)
|
||||
c.ok("that is where the reel is", start["index"] == 7, str(start))
|
||||
|
||||
print("\na swipe while the chrome is still sliding away")
|
||||
page.mouse.move(215, 500)
|
||||
page.set_viewport_size(SHORTER)
|
||||
page.wait_for_timeout(50) # well inside the realign window
|
||||
page.mouse.wheel(0, SHORTER["height"])
|
||||
page.wait_for_timeout(2200)
|
||||
swiped = page.evaluate(WHERE)
|
||||
c.ok("the swipe moved the reel on",
|
||||
swiped["index"] == start["index"] + 1,
|
||||
f"step {start['index']} -> {swiped['index']}")
|
||||
c.ok("onto the next video",
|
||||
swiped["videoId"] != start["videoId"],
|
||||
f"{start['videoId']} -> {swiped['videoId']}")
|
||||
c.ok("and it is sitting on that step, not between two",
|
||||
abs(swiped["scrollTop"] - swiped["expected"]) <= 1, str(swiped))
|
||||
c.ok("so no second video shows at the bottom of the screen",
|
||||
swiped["activeTop"] == 0, str(swiped["activeTop"]))
|
||||
|
||||
print("\na bare re-anchor in the same window is still undone")
|
||||
page.wait_for_timeout(900) # let the input grace lapse
|
||||
held = page.evaluate(WHERE)
|
||||
page.set_viewport_size(PORTRAIT)
|
||||
page.wait_for_timeout(50)
|
||||
# A position measured against the height from before the resize, which
|
||||
# is what the container itself hands back mid-rotation.
|
||||
page.evaluate(RESNAP, held["index"] * held["height"])
|
||||
page.wait_for_timeout(1800)
|
||||
resnapped = page.evaluate(WHERE)
|
||||
c.ok("the same video is still playing",
|
||||
resnapped["videoId"] == held["videoId"],
|
||||
f"{held['videoId']} -> {resnapped['videoId']}")
|
||||
c.ok("on the same step",
|
||||
resnapped["index"] == held["index"],
|
||||
f"{held['index']} -> {resnapped['index']}")
|
||||
c.ok("re-anchored onto its own snap point",
|
||||
abs(resnapped["scrollTop"] - resnapped["expected"]) <= 1, str(resnapped))
|
||||
|
||||
print("\na little height on a near-square viewport is not a rotation")
|
||||
page.set_viewport_size(SQUARE)
|
||||
page.wait_for_timeout(1800)
|
||||
page.set_viewport_size(SQUARE_SHORT)
|
||||
page.wait_for_timeout(120) # mid-window, when the pin would be on
|
||||
pinned = page.evaluate(WHERE)
|
||||
c.ok("the slides are not pinned for it", not pinned["turning"], str(pinned["turning"]))
|
||||
c.ok("so snapping stays on", pinned["snap"] != "none", str(pinned["snap"]))
|
||||
page.wait_for_timeout(1800)
|
||||
settled = page.evaluate(WHERE)
|
||||
c.ok("every slide is exactly a screenful",
|
||||
settled["offHeight"] == [], str(settled["offHeight"]))
|
||||
c.ok("and the reel rests on its snap point",
|
||||
abs(settled["scrollTop"] - settled["expected"]) <= 1, str(settled))
|
||||
c.ok("still on the same video",
|
||||
settled["videoId"] == resnapped["videoId"],
|
||||
f"{resnapped['videoId']} -> {settled['videoId']}")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user