A viewport change re-anchors the reel on the video that is playing, resolved by id rather than by a scroll position the change has already invalidated. That is what keeps a rotation on the same video. Mid-swipe it names the wrong one. setActive runs from onScroll's next frame, so a resize landing before that frame still reads the video being swiped *away from* -- and realignToActive writes its position straight to scrollTop, hauling the reel back to it. A phone slides its chrome on every scroll, so the resize lands mid-swipe on nearly every swipe: the reel went where the thumb put it, far enough to show the top of the next video at the bottom of the screen, and was then written back a beat later. The input guard in onScroll could not catch this. There is no scroll event to guard -- the write happens inside the resize. So while the reader is demonstrably driving, take the reel's own position as the authority and re-anchor on the step it is nearest at the new height. It still lands on a snap point, so nothing is left straddling two screens, but the swipe stands. Only a step's worth of disagreement counts as theirs: a scroller just re-parented into or out of the picture-in-picture window reads 0, and following that would land them on the first video instead of their own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
249 lines
10 KiB
Python
249 lines
10 KiB
Python
#!/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;
|
|
}"""
|
|
|
|
# A swipe, and the chrome reporting itself before the feed has caught up.
|
|
# setActive runs from onScroll's next frame, so doing both in one block --
|
|
# no frame boundary in between -- leaves feedActiveVideoId still naming the
|
|
# video being left, which is the state a phone resize routinely lands in.
|
|
# onResize then re-anchors synchronously, with no scroll event for the
|
|
# input guard in onScroll to see.
|
|
SWIPE_THEN_RESIZE = """() => {
|
|
const s = document.getElementById('feed-scroll');
|
|
const before = App.state.feedActiveIndex;
|
|
s.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }));
|
|
s.scrollTop = s.scrollTop + s.clientHeight;
|
|
const moved = s.scrollTop;
|
|
window.dispatchEvent(new Event('resize'));
|
|
return { before: before, moved: moved, after: s.scrollTop, h: s.clientHeight };
|
|
}"""
|
|
|
|
|
|
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("\nthe chrome reporting itself before the feed has caught up")
|
|
page.wait_for_timeout(900)
|
|
pre = page.evaluate(WHERE)
|
|
got = page.evaluate(SWIPE_THEN_RESIZE)
|
|
page.wait_for_timeout(2200)
|
|
kept = page.evaluate(WHERE)
|
|
print(f" (swiped to {got['moved']}px; the resize left it at {got['after']}px)")
|
|
c.ok("the resize did not haul the reel back to the video being left",
|
|
got["after"] >= got["moved"] - 1,
|
|
f"{got['moved']} -> {got['after']}")
|
|
c.ok("the swipe still landed on the next step",
|
|
kept["index"] == pre["index"] + 1,
|
|
f"step {pre['index']} -> {kept['index']}")
|
|
c.ok("and on that step's snap point",
|
|
abs(kept["scrollTop"] - kept["expected"]) <= 1, str(kept))
|
|
|
|
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"] == kept["videoId"],
|
|
f"{kept['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())
|