Files
jacuzzi/tests/smoke_feed_swipe.py
Simon c60f5e01dc Re-anchor a moving reel on where it is, not what it was playing
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
2026-10-27 22:26:39 +00:00

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())