#!/usr/bin/env python3 """Turning the phone moves the picture once, not a dozen times. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_rotate_smooth.py Its sibling smoke_rotate.py asks that a turn keep the same video. This one asks what the turn looks like while it happens. A turn is not one shape change: the viewport passes through every shape between the one it left and the one it lands in, and each one re-letterboxes the picture somewhere else. Painted, that run of jumps is the stutter. So the slides are held at the size the turn is heading for from its first frame: the picture steps straight into the position and size it will keep, and stays there -- no fade, no intermediate shapes, nothing dimmed. A viewport that only changes height is the browser's chrome sliding away, not a wrist, and must not be pinned: every scroll on a phone does that. """ 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} LANDSCAPE = {"width": 930, "height": 430} # The shapes a viewport goes through on its way round. TURN = [{"width": 560, "height": 800}, {"width": 700, "height": 660}, {"width": 830, "height": 520}, LANDSCAPE] # The chrome sliding away: height only, and nothing announced it. CHROME = {"width": 430, "height": 830} 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'); }""" # Samples every frame: how much of the reel is on screen, what shape the # picture has, and whether it is still where it belongs. WATCH = """() => { window.__seen = []; window.__watch = true; const scroller = document.getElementById('feed-scroll'); const tick = function() { const active = document.querySelector('.feed-slide.is-active'); const video = active ? active.querySelector('.feed-video') : null; const box = video ? video.getBoundingClientRect() : null; window.__seen.push({ opacity: Number(getComputedStyle(scroller).opacity), pinned: document.getElementById('feed-view').classList.contains('is-turning'), shape: box ? [Math.round(box.width), Math.round(box.height)] : null, top: active ? Math.round(active.getBoundingClientRect().top) : null, paused: video ? video.paused : null }); if (window.__watch) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }""" STOP = "() => { window.__watch = false; return window.__seen; }" REST = """() => { const scroller = document.getElementById('feed-scroll'); const style = getComputedStyle(scroller); return { opacity: Number(style.opacity), snap: style.scrollSnapType, overflow: style.overflowY, shape: (function() { const box = document.querySelector('.feed-slide.is-active .feed-video'); const rect = box ? box.getBoundingClientRect() : null; return rect ? [Math.round(rect.width), Math.round(rect.height)] : null; })(), turning: document.getElementById('feed-view').classList.contains('is-turning'), videoId: App.state.feedActiveVideoId, index: App.state.feedActiveIndex, scrollTop: Math.round(scroller.scrollTop), expected: App.state.feedActiveIndex * scroller.clientHeight }; }""" GO = """(step) => { const scroller = document.getElementById('feed-scroll'); scroller.scrollTop = step * scroller.clientHeight; }""" # The container's own re-anchor, on a position measured against the height # from before the turn, arriving while the new one is already in place. STALE = """(oldHeight) => { const scroller = document.getElementById('feed-scroll'); scroller.scrollTop = App.state.feedActiveIndex * oldHeight; }""" 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 shape_changes(seen): """The frames where the picture changed shape, with how lit they were.""" out = [] for a, b in zip(seen, seen[1:]): if a["shape"] and b["shape"] and a["shape"] != b["shape"]: out.append({"from": a["shape"], "to": b["shape"], "opacity": b["opacity"]}) return out def do_turn(page, sizes, old_height, announced=True): page.evaluate(WATCH) page.wait_for_timeout(120) if announced: page.evaluate("() => window.dispatchEvent(new Event('orientationchange'))") for i, size in enumerate(sizes): page.set_viewport_size(size) if i == 1: page.evaluate(STALE, old_height) page.wait_for_timeout(70) page.wait_for_timeout(1400) return page.evaluate(STOP) 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) for step in (1, 2, 3): page.evaluate(GO, step) page.wait_for_timeout(250) page.wait_for_timeout(800) before = page.evaluate(REST) print("\nturning it sideways, one shape at a time") seen = do_turn(page, TURN, PORTRAIT["height"]) changes = shape_changes(seen) rest = page.evaluate(REST) c.ok("the picture moved once, not through every shape on the way", len(changes) == 1, str(changes)) c.ok("and the one step it took was straight into the size it keeps", bool(changes) and changes[0]["to"] == rest["shape"], f"{changes} -> {rest['shape']}") c.ok("nothing was dimmed to cover it", all(s["opacity"] == 1 for s in seen), str(min(s["opacity"] for s in seen))) c.ok("the reel stayed pinned to its step the whole way", all(s["top"] in (None, 0) for s in seen), str(sorted({s["top"] for s in seen}))) c.ok("the slides were held only for the turn, not after it", 0 < len([s for s in seen if s["pinned"]]) < len(seen) * 0.75, f"{len([s for s in seen if s['pinned']])} of {len(seen)} frames") c.ok("the clip never paused for it", not any(s["paused"] for s in seen), "") c.ok("and the feed is no longer marked as turning", not rest["turning"], str(rest)) c.ok("snapping is back on", "y" in rest["snap"], str(rest["snap"])) c.ok("and so is scrolling", rest["overflow"] == "scroll", str(rest["overflow"])) c.ok("on the same video it was on", rest["videoId"] == before["videoId"], f"{before['videoId']} -> {rest['videoId']}") c.ok("sitting on its snap point", abs(rest["scrollTop"] - rest["expected"]) <= 1, str(rest)) print("\nand back upright") seen = do_turn(page, list(reversed(TURN[:-1])) + [PORTRAIT], LANDSCAPE["height"]) changes = shape_changes(seen) back = page.evaluate(REST) c.ok("one step on the way back too", len(changes) == 1, str(changes)) c.ok("into the size it keeps", bool(changes) and changes[0]["to"] == back["shape"], f"{changes} -> {back['shape']}") c.ok("still the same video", back["videoId"] == before["videoId"], str(back)) print("\nthe browser's chrome sliding away is not a turn") seen = do_turn(page, [CHROME], PORTRAIT["height"], announced=False) c.ok("the slides were never pinned for it", not any(s["pinned"] for s in seen), "") page.set_viewport_size(PORTRAIT) page.wait_for_timeout(1200) chrome = page.evaluate(REST) c.ok("and it is still on the same video", chrome["videoId"] == before["videoId"], str(chrome)) c.ok("nothing threw along the way", not crashes, str(crashes[:2])) browser.close() print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed") return 1 if c.failed else 0 sys.exit(main())