Keep the videos already playing when a panel is split
Splitting or closing a panel changes the stride, which renumbers every step, so the slides are rebuilt -- and the rebuild threw away the panes along with them. Every video on screen was fetched and buffered again to show what it was already showing. The panes are now set aside by video id first, and whoever needs that video next takes the pane that already has it: the same element, the same stream, the frames it had already decoded. It all happens in one go without yielding, so a pane is never out of the document long enough to be paused for it. Panes the new layout has no place for are torn down as before -- they are holding a stream open. Pane tools are rebindable for the same reason: a reused pane comes back under a different node in the tree, and its old buttons would otherwise split and close the one it left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -653,7 +653,7 @@ App.feed = App.feed || {};
|
||||
// around whatever video is playing so the reader keeps their place.
|
||||
const rebuildLayout = function() {
|
||||
const keepId = state.feedActiveVideoId;
|
||||
teardownAllSlides();
|
||||
reserveSlides();
|
||||
const videos = state.loadedVideos || [];
|
||||
let videoIndex = 0;
|
||||
if (keepId != null) {
|
||||
@@ -663,23 +663,66 @@ App.feed = App.feed || {};
|
||||
state.feedActiveIndex = -1;
|
||||
const step = Math.floor(videoIndex / paneCount());
|
||||
setActive(step);
|
||||
releaseReserve();
|
||||
const scroller = getScroller();
|
||||
if (scroller) scroller.scrollTop = step * slideHeight();
|
||||
};
|
||||
|
||||
const teardownAllSlides = function() {
|
||||
// ------------------------------------------------------------------
|
||||
// Reusing panes across a layout change
|
||||
//
|
||||
// Splitting or closing a panel changes the stride, so every step is
|
||||
// renumbered and the slides have to be built again -- but the videos
|
||||
// haven't changed. A pane already holds one: its <video> with the bytes it
|
||||
// has buffered, where it had got to, which quality it was told to play.
|
||||
// Building a fresh one throws all of that away and fetches it again, so
|
||||
// the panes are set aside by video id first and whoever needs that video
|
||||
// next takes the pane that already has it.
|
||||
//
|
||||
// Everything happens in one go, without yielding, so a pane never stops
|
||||
// being in the document long enough to be paused for it.
|
||||
// ------------------------------------------------------------------
|
||||
const paneReserve = new Map();
|
||||
|
||||
const reserveSlides = function() {
|
||||
slidesByIndex.forEach((slide) => {
|
||||
teardownSlide(slide);
|
||||
panesOf(slide).forEach((pane) => {
|
||||
const id = slideVideoId(pane);
|
||||
const key = id == null ? null : String(id);
|
||||
if (key !== null && !paneReserve.has(key)) {
|
||||
paneReserve.set(key, pane);
|
||||
if (pane.parentNode) pane.parentNode.removeChild(pane);
|
||||
return;
|
||||
}
|
||||
teardownPane(pane); // a second pane on the same video
|
||||
});
|
||||
slide._panes = [];
|
||||
slide.remove();
|
||||
});
|
||||
slidesByIndex.clear();
|
||||
forgetPeeks();
|
||||
};
|
||||
|
||||
// Whatever the new layout had no place for -- it is holding a stream open.
|
||||
const releaseReserve = function() {
|
||||
paneReserve.forEach(teardownPane);
|
||||
paneReserve.clear();
|
||||
};
|
||||
|
||||
// Everything one video needs on screen. Identical to what a slide used to
|
||||
// hold: the per-video helpers below take any element shaped like this, so a
|
||||
// pane and the old single-video slide are interchangeable to them.
|
||||
const buildPane = function(v, index) {
|
||||
const held = v && v.id != null ? paneReserve.get(String(v.id)) : null;
|
||||
if (held) {
|
||||
paneReserve.delete(String(v.id));
|
||||
// The stride changed, so this video sits at a different place in
|
||||
// the queue than it did; nothing else about the pane has moved.
|
||||
held.dataset.index = String(index);
|
||||
held._index = index;
|
||||
held._videoData = v;
|
||||
return held;
|
||||
}
|
||||
const pane = document.createElement('div');
|
||||
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
|
||||
pane.dataset.videoId = v.id;
|
||||
@@ -814,21 +857,36 @@ App.feed = App.feed || {};
|
||||
return split.childElementCount ? split : null;
|
||||
};
|
||||
|
||||
// Rebindable, because a pane outlives the tree it was built for: a reused
|
||||
// pane comes back under a different node, and its old buttons would still
|
||||
// be splitting and closing the one it left.
|
||||
const bindPaneTools = function(pane, node) {
|
||||
if (Array.isArray(pane._toolCleanups)) {
|
||||
pane._toolCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||
}
|
||||
const cleanups = [];
|
||||
const on = function(selector, handler) {
|
||||
const el = pane.querySelector(selector);
|
||||
if (!el) return el;
|
||||
el.addEventListener('click', handler);
|
||||
cleanups.push(() => el.removeEventListener('click', handler));
|
||||
return el;
|
||||
};
|
||||
const closeBtn = pane.querySelector('.feed-pane-close');
|
||||
closeBtn.hidden = paneCount() <= 1;
|
||||
pane.querySelector('.feed-pane-split-right').addEventListener('click', (event) => {
|
||||
on('.feed-pane-split-right', (event) => {
|
||||
event.stopPropagation();
|
||||
splitPane(node, 'row');
|
||||
});
|
||||
pane.querySelector('.feed-pane-split-down').addEventListener('click', (event) => {
|
||||
on('.feed-pane-split-down', (event) => {
|
||||
event.stopPropagation();
|
||||
splitPane(node, 'col');
|
||||
});
|
||||
closeBtn.addEventListener('click', (event) => {
|
||||
on('.feed-pane-close', (event) => {
|
||||
event.stopPropagation();
|
||||
closePane(node);
|
||||
});
|
||||
pane._toolCleanups = cleanups;
|
||||
};
|
||||
|
||||
// One screenful: the pane tree, filled with this step's run of videos.
|
||||
|
||||
348
tests/smoke_split_reuse.py
Normal file
348
tests/smoke_split_reuse.py
Normal file
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Splitting a panel keeps the video that is already playing, rather than
|
||||
fetching it again.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_split_reuse.py
|
||||
|
||||
The clip server counts what is asked of it, which is the whole point: a split
|
||||
renumbers every step and rebuilds the slides, and the question is whether the
|
||||
bytes already in hand survive that. The listing is a fixture for the same
|
||||
reason as everywhere else -- so the answer is about the feed, not a CDN.
|
||||
|
||||
Each panel's <video> is tagged, so a rebuilt panel is told apart from a reused
|
||||
one by the element rather than by what it is showing.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
PER_PAGE = 12
|
||||
PAGES = 8
|
||||
MEDIA_DELAY_MS = 300 # what a clip costs if it isn't already loading
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the panels have to
|
||||
# decode a frame from it. Same blob as smoke_open.py.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
def make_server(fetched):
|
||||
"""Serves the fixture clip, counting who asked for what."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
fetched[self.path] = fetched.get(self.path, 0) + 1
|
||||
time.sleep(MEDIA_DELAY_MS / 1000)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self):
|
||||
fetched[self.path] = fetched.get(self.path, 0) + 1
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
# Which videos were asked to resolve their formats. Prefilled formats make the
|
||||
# answer immediate, but the asking is what says when the feed goes looking.
|
||||
SPY = """() => {
|
||||
window.__resolved = [];
|
||||
const real = App.videos.ensureFormats;
|
||||
App.videos.ensureFormats = function(video) {
|
||||
if (video && video.id) window.__resolved.push(String(video.id));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
}"""
|
||||
|
||||
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
|
||||
step: Number(slide.dataset.step),
|
||||
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
|
||||
.map((pane) => pane.classList.contains('is-loaded')),
|
||||
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
|
||||
}))"""
|
||||
|
||||
WATCH = """() => {
|
||||
window.__t0 = performance.now();
|
||||
window.__started = {};
|
||||
document.addEventListener('playing', (e) => {
|
||||
const pane = e.target.closest && e.target.closest('.feed-pane');
|
||||
if (!pane) return;
|
||||
const id = pane.dataset.videoId;
|
||||
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
|
||||
}, true);
|
||||
}"""
|
||||
|
||||
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click(); // dispatched, so the pointer never wakes the HUD
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
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 make_items(page_no, media):
|
||||
"""One page of the listing, formats already resolved so nothing but the
|
||||
media itself stands between a panel and its first frame."""
|
||||
start = (page_no - 1) * PER_PAGE
|
||||
items = []
|
||||
for i in range(PER_PAGE):
|
||||
n = start + i
|
||||
url = f"{media}/v{n}.mp4"
|
||||
items.append({
|
||||
"id": f"fix-{n}",
|
||||
"title": f"Fixture {n}",
|
||||
"url": "https://example.test/watch/%d" % n,
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
|
||||
|
||||
# What a panel is holding: the element itself (tagged), where it has got to,
|
||||
# and whether it still has its source.
|
||||
MARK = """() => {
|
||||
document.querySelectorAll('.feed-pane').forEach((pane, i) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
if (video && !video.__tag) video.__tag = 'v' + i + '-' + pane.dataset.videoId;
|
||||
});
|
||||
}"""
|
||||
|
||||
PANELS = """() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-pane')).map((pane) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
return {
|
||||
id: pane.dataset.videoId,
|
||||
tag: video ? (video.__tag || null) : null,
|
||||
t: video ? Math.round(video.currentTime * 100) / 100 : null,
|
||||
paused: video ? video.paused : null,
|
||||
buffered: video && video.buffered.length ? Math.round(video.buffered.end(0) * 100) / 100 : 0,
|
||||
ready: video ? video.readyState : -1,
|
||||
loaded: pane.classList.contains('is-loaded'),
|
||||
};
|
||||
})"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click();
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
fetched = {}
|
||||
httpd = make_server(fetched)
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
parked = []
|
||||
|
||||
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=768",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 800, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
def answer():
|
||||
while parked:
|
||||
route = parked.pop(0)
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
page_no = int(body.get("page", 1) or 1)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"items": make_items(page_no, media) if page_no <= PAGES else [],
|
||||
"pageInfo": {"hasNextPage": page_no < PAGES},
|
||||
}))
|
||||
|
||||
def settle(ms):
|
||||
for _ in range(max(1, ms // 100)):
|
||||
page.wait_for_timeout(100)
|
||||
answer()
|
||||
|
||||
page.route("**/api/videos", lambda route: parked.append(route))
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
settle(1500)
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
settle(4000)
|
||||
|
||||
page.evaluate(MARK)
|
||||
before = page.evaluate(PANELS)
|
||||
watched = before[0]["id"]
|
||||
print(f" (watching {watched}, at {before[0]['t']}s)")
|
||||
c.ok("one panel to start with", len(before) == 1, str(before))
|
||||
c.ok("and it is playing", before[0]["paused"] is False, str(before[0]))
|
||||
asked_before = dict(fetched)
|
||||
|
||||
print("\nsplitting it in two")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane .feed-pane-split-right")
|
||||
settle(2500)
|
||||
after = page.evaluate(PANELS)
|
||||
kept = [pane for pane in after if pane["id"] == watched]
|
||||
print(f" (panels now: {[(x['id'], x['tag'], x['t']) for x in after]})")
|
||||
c.ok("there are two panels", len(after) == 2, str(after))
|
||||
c.ok("the video being watched is still one of them", len(kept) == 1, str(after))
|
||||
c.ok("in the same element it was playing in -- not a new one",
|
||||
kept and kept[0]["tag"] == before[0]["tag"], f"{kept and kept[0]['tag']} vs {before[0]['tag']}")
|
||||
# Not currentTime: the fixture clip is a second long and the feed loops
|
||||
# it, so where it has got to says nothing. What says it never reloaded
|
||||
# is that it still has decoded frames and a buffer.
|
||||
c.ok("with the frames it had already decoded, not back at nothing",
|
||||
kept and kept[0]["ready"] >= 2 and kept[0]["buffered"] > 0, str(kept))
|
||||
c.ok("and it is still playing", kept and kept[0]["paused"] is False, str(kept))
|
||||
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
watched_path = "/v0.mp4"
|
||||
c.ok("its bytes were not fetched a second time",
|
||||
watched_path not in again,
|
||||
f"asked for again: {sorted(again)}")
|
||||
|
||||
print("\nand splitting once more")
|
||||
page.evaluate(MARK) # tag first, then read: an untagged pane
|
||||
marks = page.evaluate(PANELS) # on both sides would compare None to None
|
||||
asked_before = dict(fetched)
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-down")
|
||||
settle(2500)
|
||||
three = page.evaluate(PANELS)
|
||||
held = {pane["id"]: pane for pane in three}
|
||||
c.ok("three panels now", len(three) == 3, str(three))
|
||||
expected = [m for m in marks if m["id"] in held]
|
||||
carried = [m for m in expected if held[m["id"]]["tag"] == m["tag"]]
|
||||
c.ok("both panels that were already playing carried their element over",
|
||||
expected and all(m["tag"] for m in expected) and len(carried) == len(expected),
|
||||
str(three))
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
c.ok("and neither was fetched again",
|
||||
not any(f"/v{i}.mp4" in again for i in (0, 1)), f"asked for again: {sorted(again)}")
|
||||
|
||||
print("\nclosing one of them")
|
||||
asked_before = dict(fetched)
|
||||
page.evaluate(MARK)
|
||||
marks = page.evaluate(PANELS)
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
|
||||
settle(2500)
|
||||
left = page.evaluate(PANELS)
|
||||
held = {pane["id"]: pane for pane in left}
|
||||
c.ok("two panels left", len(left) == 2, str(left))
|
||||
survivors = [m for m in marks if m["id"] in held]
|
||||
c.ok("the survivors kept their elements",
|
||||
survivors and all(m["tag"] and held[m["id"]]["tag"] == m["tag"] for m in survivors),
|
||||
str(left))
|
||||
c.ok("still playing", all(pane["paused"] is False for pane in left), str(left))
|
||||
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
|
||||
c.ok("and nothing that was already loaded was fetched again",
|
||||
not any(f"/v{i}.mp4" in again for i in (0, 1, 2)), f"asked for again: {sorted(again)}")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
httpd.shutdown()
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user