Files
jacuzzi/tests/smoke_saved_searches.py
Simon bc7c0047b7 Keep a whole search, and say what is new in it
A search here is more than the words in the box: it is the source, the
channel, every filter that channel offers, and the query together. The
bookmark in the search bar keeps all of it, the one in the header opens
the list, and tapping a row puts the whole thing back.

Each saved search remembers the ids of the newest videos it showed the
last time it was opened. Once a day -- at startup, when the tab is looked
at again, and on a slow heartbeat for tabs left open -- the client asks
each one's first page again and counts how many of those videos it has
never shown. That count is the badge, on the row and summed in the
header, and it stops at 9+. Opening a saved search is viewing it, so the
count goes out and the mark moves to what is on screen now.

Nothing new leaves the device: the check is the same listing request the
grid would have made, and everything else is localStorage.

Also: handleSearch's "put this in the box without searching for it" half
is now App.videos.setSearchBox, which is what applying a saved search
needs.

tests/smoke_saved_searches.py covers it end to end against a fixture
listing whose ids the test shifts to mean "new uploads": 46 checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-17 05:59:19 +00:00

324 lines
15 KiB
Python

#!/usr/bin/env python3
"""A whole search -- source, channel, filters, query -- can be kept and come back to.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_saved_searches.py
Both the status and the listing are fixtures, so the test owns what "new"
means: it changes the ids the listing hands back and then asks the client how
many of them it has never shown.
"""
import json
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [{
"id": "sort",
"title": "Sort",
"options": [
{"id": "latest", "title": "Latest"},
{"id": "views", "title": "Most viewed"},
],
}],
}],
}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('searchHistory');
localStorage.removeItem('savedSearches');
}"""
SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')"""
BADGE = """() => {
const badge = document.getElementById('saved-searches-badge');
return { hidden: badge.hidden, text: badge.textContent };
}"""
ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '',
count: row.querySelector('.saved-count').textContent,
quiet: row.querySelector('.saved-count').classList.contains('is-quiet'),
meta: row.querySelector('.saved-row-meta').textContent,
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
SESSION = """() => {
const s = JSON.parse(localStorage.getItem('session'));
return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : 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 main():
c = Checks()
asked = []
# Which ids the listing hands back. Shifting this window is how the test
# says "three new videos were uploaded".
offset = {"at": 0, "fail": False}
def items(query):
start = offset["at"]
return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 1, "isLive": False, "tags": []}
for i in range(start, start + 12)]
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": 1180, "height": 920})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
def status(route):
route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS))
def listing(route):
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
asked.append(body)
if offset["fail"]:
route.fulfill(status=500, content_type="application/json", body="{}")
return
route.fulfill(status=200, content_type="application/json", body=json.dumps(
{"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}}))
page.route("**/api/status", status)
page.route("**/api/videos", listing)
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(400)
print("\nthe feature is there at all")
c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function')
c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True)
c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False)
print("\nkeeping the search that is on screen")
page.fill("#search-input", "")
page.type("#search-input", "sauna", delay=30)
page.wait_for_timeout(900)
page.click("#search-save-btn")
page.wait_for_timeout(200)
saved = page.evaluate(SAVED)
c.ok("one search was kept", len(saved) == 1, str(saved))
entry = saved[0] if saved else {}
c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query")))
c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId")))
c.ok("the source", entry.get("server") == SERVER, str(entry.get("server")))
c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options")))
c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name")))
c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or [])))
c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True)
c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True)
print("\nthe bookmark is about this search, not just this channel")
page.fill("#search-input", "")
page.type("#search-input", "pool", delay=30)
page.wait_for_timeout(300)
c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False)
page.fill("#search-input", "")
page.type("#search-input", "sauna", delay=30)
page.wait_for_timeout(300)
c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True)
print("\nand a filter is part of it too")
page.evaluate("""() => {
const s = JSON.parse(localStorage.getItem('session'));
s.options.sort = { id: 'views', title: 'Most viewed' };
localStorage.setItem('session', JSON.stringify(s));
App.ui.renderMenu();
}""")
page.wait_for_timeout(200)
c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False)
print("\nthree videos turn up while nobody is looking")
offset["at"] = 3
asked.clear()
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("the check asked for the saved search, not what is on screen",
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount")))
badge = page.evaluate(BADGE)
c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge))
print("\na busy day")
offset["at"] = 40
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount")))
c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge))
print("\nonce a day, not once a page view")
asked.clear()
page.evaluate("() => App.savedSearches.check()")
page.wait_for_timeout(500)
c.ok("a search checked a moment ago is left alone", asked == [], str(asked))
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000;
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
page.evaluate("() => App.savedSearches.check()")
page.wait_for_timeout(900)
c.ok("one last checked two days ago is asked about",
any(b.get("query") == "sauna" for b in asked), str(asked))
print("\nthe list")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
rows = page.evaluate(ROWS)
c.ok("it opens", page.evaluate(PANEL_OPEN) is True)
c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows))
c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0]))
c.ok("and saying where it points",
"Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"],
rows[0]["meta"])
print("\ntapping it puts the whole search back")
asked.clear()
page.click("#saved-panel .saved-row-main")
page.wait_for_timeout(1200)
c.ok("the list closes", page.evaluate(PANEL_OPEN) is False)
c.ok("the words are back in the box",
page.evaluate("() => document.getElementById('search-input').value") == "sauna")
session = page.evaluate(SESSION)
c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session))
c.ok("and that is what was fetched",
any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
saved = page.evaluate(SAVED)
c.ok("opening it is viewing it, so the count goes out",
saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0]))
c.ok("and what it showed is the new mark",
"vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3]))
c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True)
print("\nnothing new, said quietly")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
rows = page.evaluate(ROWS)
c.ok("no number where there is no news",
rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0]))
print("\nrenaming one")
page.click("#saved-panel .saved-tool[data-act='rename']")
page.wait_for_timeout(250)
page.fill("#saved-panel .saved-rename", "Sauna, newest first")
page.keyboard.press("Enter")
page.wait_for_timeout(300)
saved = page.evaluate(SAVED)
c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name")))
c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first")
print("\nsaving a second one from the list itself")
page.evaluate("() => App.ui.savedSearches.close()")
page.fill("#search-input", "")
page.type("#search-input", "steam", delay=30)
page.wait_for_timeout(900)
page.click("#saved-searches-btn")
page.wait_for_timeout(250)
page.click("#saved-add-btn")
page.wait_for_timeout(300)
saved = page.evaluate(SAVED)
c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved]))
c.ok("the new one is first and named after its query",
saved[0].get("query") == "steam", str(saved[0].get("name")))
c.ok("and it opens ready to be named",
page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')"))
page.keyboard.press("Escape")
page.wait_for_timeout(200)
print("\nremoving one asks first")
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
page.wait_for_timeout(250)
c.ok("the row asks", page.evaluate(
"() => !!document.querySelector('#saved-panel .saved-confirm')"))
page.click("#saved-panel .saved-confirm-btn:not(.is-danger)")
page.wait_for_timeout(250)
c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2)
page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
page.wait_for_timeout(250)
page.click("#saved-panel .saved-confirm-btn.is-danger")
page.wait_for_timeout(250)
saved = page.evaluate(SAVED)
c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved))
print("\na source that does not answer")
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].newCount = 0;
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
offset["fail"] = True
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(1200)
saved = page.evaluate(SAVED)
c.ok("is not a search with nothing new in it",
saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0]))
rows = page.evaluate(ROWS)
c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
offset["fail"] = False
print("\nacross a reload")
page.evaluate("""() => {
const all = JSON.parse(localStorage.getItem('savedSearches'));
all[0].newCount = 4;
all[0].unreachable = false;
all[0].lastCheckedAt = Date.now();
localStorage.setItem('savedSearches', JSON.stringify(all));
}""")
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(600)
badge = page.evaluate(BADGE)
c.ok("the count is on screen straight from storage",
badge["hidden"] is False and badge["text"] == "4", str(badge))
c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
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())