A saved search is one source, one channel on it, that channel's filters and the words. Two of them on different sources had no way to be read together, and a channel group is not it: every channel in a group belongs to the same server and shares one query, where the interesting pair is "uncensored JAV over here" and "amateur 1080p over there". So a saved search can now hold several of those instead of one. Opening it feeds a single grid from all of them, taken alternately -- one from each in turn, the way a group zips its channels -- rather than one source's page and then the next. Each member keeps its own source, channel, filters and words, because having its own words is most of why it was worth saving separately. Every entry is read as a list of members, a plain search being a merge of one. That is what lets the daily check, mark-all-read and the bookmark go on working without learning what a merge is: they ask each member and zip the answers, so the badge counts what the grid would have shown. The search box is turned off while a merge is open and says why -- there is nothing for one box to say for several members. A tag or an uploader tapped on a card steps out of the merge onto one real channel instead, since otherwise it would reload the same videos and look broken. A merge answers to no single server, so it is not stored as a channel preference and is not looked up from a server's status: its members ride on the session, and a reload rebuilds it from the saved search so a rename is picked up rather than frozen in. One source failing drops that member and leaves the rest readable. Merging a merge flattens it, so "the searches in it" means the same however it was assembled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
264 lines
11 KiB
Python
264 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Merging saved searches from different providers into one listing.
|
|
|
|
Run against a locally running backend:
|
|
|
|
backend/main.py &
|
|
.venv/bin/python tests/smoke_merge_searches.py
|
|
|
|
Two fixture providers, one saved search on each, merged into one. What the
|
|
merge has to do that a channel group cannot: a group's channels all live on
|
|
one server and share one query, where these two share nothing at all. So the
|
|
load-bearing check is that the grid comes back holding videos from *both*
|
|
providers, taken alternately rather than one provider's page and then the
|
|
other's.
|
|
|
|
Both providers are fixtures, so "interleaved" is something the test can read
|
|
off the ids rather than infer: provider A answers a-0, a-1 ... and B answers
|
|
b-0, b-1 ...
|
|
"""
|
|
import json
|
|
import sys
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
BASE = "http://127.0.0.1:5000/"
|
|
SERVER_A = "https://a.fixture.test"
|
|
SERVER_B = "https://b.fixture.test"
|
|
CHANNEL_A = "alpha"
|
|
CHANNEL_B = "beta"
|
|
# fetchMergedBatch asks each member for this many (GROUP_CHANNEL_PAGE_SIZE).
|
|
PER_MEMBER = 4
|
|
|
|
|
|
def status(name, channel_id, channel_name):
|
|
return {
|
|
"id": name,
|
|
"name": name,
|
|
"online": True,
|
|
"channels": [{
|
|
"id": channel_id,
|
|
"name": channel_name,
|
|
"description": "A channel that only exists for this test.",
|
|
"options": [],
|
|
}],
|
|
}
|
|
|
|
|
|
STATUS_A = status("alpha-co", CHANNEL_A, "Alpha Tube")
|
|
STATUS_B = status("beta-co", CHANNEL_B, "Beta Tube")
|
|
|
|
# Two saved searches, one per provider, seeded straight into the store -- the
|
|
# saving of a search is smoke_saved_searches' business, not this test's.
|
|
SEED = """([a, b, ca, cb]) => {
|
|
localStorage.setItem('config', JSON.stringify({
|
|
servers: [{ [a]: {} }, { [b]: {} }]
|
|
}));
|
|
localStorage.removeItem('session');
|
|
localStorage.setItem('favorites', JSON.stringify([]));
|
|
localStorage.setItem('savedSearches', JSON.stringify([
|
|
{ id: 'ss_a', name: 'Alpha pick', server: a, channelId: ca,
|
|
channelName: 'Alpha Tube', query: 'reef', options: {}, optionLabels: [],
|
|
createdAt: 1, seenIds: [], lastViewedAt: 1, lastCheckedAt: 1, newCount: 0 },
|
|
{ id: 'ss_b', name: 'Beta pick', server: b, channelId: cb,
|
|
channelName: 'Beta Tube', query: 'ridge', options: {}, optionLabels: [],
|
|
createdAt: 2, seenIds: [], lastViewedAt: 2, lastCheckedAt: 2, newCount: 0 }
|
|
]));
|
|
}"""
|
|
|
|
GRID_IDS = """() => (App.state.loadedVideos || []).map(v => v.id)"""
|
|
|
|
SESSION = """() => {
|
|
const s = App.storage.getSession();
|
|
return {
|
|
merged: !!(s && s.channel && s.channel.isMerged),
|
|
channelId: s && s.channel ? s.channel.id : null,
|
|
members: s && s.channel && s.channel.members
|
|
? s.channel.members.map(m => m.server + '|' + m.channelId + '|' + m.query)
|
|
: null,
|
|
name: s && s.channel ? s.channel.name : null
|
|
};
|
|
}"""
|
|
|
|
STORE = """() => App.savedSearches.getAll().map(e => ({
|
|
id: e.id, name: e.name,
|
|
members: e.members ? e.members.map(m => m.server) : null
|
|
}))"""
|
|
|
|
|
|
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 install_routes(page, seen):
|
|
page.route(f"{SERVER_A}/api/status", lambda route: route.fulfill(
|
|
status=200, content_type="application/json", body=json.dumps(STATUS_A)))
|
|
page.route(f"{SERVER_B}/api/status", lambda route: route.fulfill(
|
|
status=200, content_type="application/json", body=json.dumps(STATUS_B)))
|
|
|
|
def videos(route):
|
|
try:
|
|
body = json.loads(route.request.post_data or "{}")
|
|
except ValueError:
|
|
body = {}
|
|
server = body.get("server") or ""
|
|
page_no = int(body.get("page") or 1)
|
|
per = int(body.get("perPage") or 12)
|
|
tag = "a" if server == SERVER_A else ("b" if server == SERVER_B else "?")
|
|
seen.append({"server": server, "channel": body.get("channel"),
|
|
"query": body.get("query"), "page": page_no, "perPage": per})
|
|
start = (page_no - 1) * per
|
|
items = [{
|
|
"id": f"{tag}-{start + i}",
|
|
"title": f"{tag} clip {start + i}",
|
|
"url": f"{server}/v{start + i}",
|
|
"thumb": "", "channel": body.get("channel"),
|
|
"duration": 30, "isLive": False, "tags": [],
|
|
} for i in range(per)]
|
|
route.fulfill(status=200, content_type="application/json",
|
|
body=json.dumps({"items": items, "pageInfo": {"hasNextPage": True}}))
|
|
|
|
page.route("**/api/videos", videos)
|
|
|
|
|
|
def main():
|
|
c = Checks()
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(args=[
|
|
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
|
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
|
|
])
|
|
page = browser.new_page(viewport={"width": 1280, "height": 900})
|
|
crashes = []
|
|
page.on("pageerror", lambda e: crashes.append(str(e)))
|
|
seen = []
|
|
install_routes(page, seen)
|
|
|
|
page.goto(BASE, wait_until="domcontentloaded")
|
|
page.evaluate(SEED, [SERVER_A, SERVER_B, CHANNEL_A, CHANNEL_B])
|
|
page.goto(BASE, wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=30000)
|
|
page.wait_for_timeout(800)
|
|
|
|
print("\nmerging the two")
|
|
page.click("#saved-searches-btn")
|
|
page.wait_for_selector("#saved-panel.open", timeout=10000)
|
|
rows = page.eval_on_selector_all(".saved-row", "els => els.length")
|
|
c.ok("both saved searches are listed", rows == 2, str(rows))
|
|
|
|
page.click("#saved-select-btn")
|
|
page.wait_for_timeout(200)
|
|
page.click('.saved-row[data-id="ss_a"] .saved-row-main')
|
|
page.click('.saved-row[data-id="ss_b"] .saved-row-main')
|
|
page.wait_for_timeout(200)
|
|
label = page.eval_on_selector("#saved-merge-btn", "el => el.textContent")
|
|
c.ok("the merge action counts what is picked",
|
|
"2" in label, label)
|
|
|
|
page.click("#saved-merge-btn")
|
|
page.wait_for_timeout(400)
|
|
# The new merge opens straight into its rename field; Enter keeps the
|
|
# name it was given.
|
|
page.keyboard.press("Enter")
|
|
page.wait_for_timeout(300)
|
|
|
|
store = page.evaluate(STORE)
|
|
c.ok("the two originals were replaced by one merge", len(store) == 1, str(store))
|
|
c.ok("and it holds a member from each provider",
|
|
len(store) == 1 and sorted(store[0]["members"] or []) == sorted([SERVER_A, SERVER_B]),
|
|
str(store))
|
|
|
|
print("\nopening it")
|
|
seen.clear()
|
|
page.click(f'.saved-row[data-id="{store[0]["id"]}"] .saved-row-main')
|
|
page.wait_for_timeout(2500)
|
|
|
|
session = page.evaluate(SESSION)
|
|
c.ok("the session is a merge", session["merged"], str(session))
|
|
c.ok("carrying both members, each with its own words",
|
|
sorted(session["members"] or []) == sorted([
|
|
f"{SERVER_A}|{CHANNEL_A}|reef", f"{SERVER_B}|{CHANNEL_B}|ridge"]),
|
|
str(session["members"]))
|
|
|
|
asked = {(r["server"], r["query"]) for r in seen}
|
|
c.ok("both providers were asked, each for its own search",
|
|
asked == {(SERVER_A, "reef"), (SERVER_B, "ridge")}, str(asked))
|
|
|
|
ids = page.evaluate(GRID_IDS)
|
|
kinds = [i.split("-")[0] for i in ids]
|
|
c.ok("the grid holds videos from both providers",
|
|
"a" in kinds and "b" in kinds, str(ids[:10]))
|
|
# The point of the exercise: alternated, not one provider then the other.
|
|
head = kinds[:2 * PER_MEMBER]
|
|
c.ok("taken alternately rather than one provider at a time",
|
|
head == ["a", "b"] * PER_MEMBER, str(head))
|
|
|
|
print("\nthe search box stays out of it")
|
|
box = page.evaluate("""() => {
|
|
const el = document.getElementById('search-input');
|
|
return { disabled: el.disabled, value: el.value, placeholder: el.placeholder };
|
|
}""")
|
|
c.ok("the box is off while a merge is open", box["disabled"], str(box))
|
|
c.ok("and says why", "own words" in box["placeholder"], str(box["placeholder"]))
|
|
|
|
print("\nit survives a reload")
|
|
page.reload(wait_until="load")
|
|
page.wait_for_selector(".video-card", timeout=30000)
|
|
page.wait_for_timeout(2000)
|
|
again = page.evaluate(SESSION)
|
|
c.ok("still a merge after reloading", again["merged"], str(again))
|
|
c.ok("with the same members",
|
|
sorted(again["members"] or []) == sorted(session["members"] or []),
|
|
str(again["members"]))
|
|
reloaded = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
|
|
c.ok("and still fed by both providers",
|
|
"a" in reloaded and "b" in reloaded, str(reloaded[:10]))
|
|
|
|
print("\nturning one member off")
|
|
page.evaluate("""(host) => {
|
|
const s = App.storage.getSession();
|
|
const keep = s.channel.members.filter(m => m.server.indexOf(host) !== -1);
|
|
s.options = { __mergedMembers: keep.map(m => ({ id: m.memberId, title: m.channelName })) };
|
|
App.storage.setSession(s);
|
|
}""", "a.fixture")
|
|
page.evaluate("() => { App.videos.resetGrid(); return App.videos.loadVideos(); }")
|
|
page.wait_for_timeout(2000)
|
|
only = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
|
|
c.ok("only the member left on is asked",
|
|
only and set(only) == {"a"}, str(only[:10]))
|
|
|
|
print("\nsearching from inside a merge steps out of it")
|
|
# A tag or an uploader tapped on a card comes through handleSearch.
|
|
# There is nowhere in a merge to put new words, so rather than reload
|
|
# the same videos and look broken, it lands on one real channel.
|
|
page.evaluate("() => App.videos.handleSearch('anemone')")
|
|
page.wait_for_timeout(2000)
|
|
after = page.evaluate(SESSION)
|
|
c.ok("the session is no longer a merge", not after["merged"], str(after))
|
|
c.ok("it landed on a member's own channel",
|
|
after["channelId"] in (CHANNEL_A, CHANNEL_B), str(after["channelId"]))
|
|
box2 = page.evaluate("""() => {
|
|
const el = document.getElementById('search-input');
|
|
return { disabled: el.disabled, value: el.value };
|
|
}""")
|
|
c.ok("and the box is usable again", not box2["disabled"], str(box2))
|
|
c.ok("holding what was searched for", box2["value"] == "anemone", str(box2))
|
|
searched = [r for r in seen if r["query"] == "anemone"]
|
|
c.ok("which is what the provider was asked for", bool(searched), str(seen[-3:]))
|
|
|
|
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())
|