Files
jacuzzi/tests/smoke_saved_searches.py
Simon d5c859a1c7 Count only the part of a page the reader was shown
`perPage` is a request, not a promise: a source whose own pages are 30
long answers a page of 12 with 30. The grid showed the reader the page it
asked for, the mark kept 24 of those ids, and the daily check then counted
every probe id the mark did not contain -- including ids 25 to 30 of the
same page, which were never new and no amount of looking took off the
badge. A saved search on sxyprn sat on "6" through every reload.

So the count now goes only as far down the page as the mark reaches, and
the mark holds enough ids (60) for a page larger than the one asked for.
Order does not come into it, which keeps it right for sources that shift a
listing between visits.

And the whole list can be marked read in one press, for a reader who sees
the badge and decides none of it is for them. That moves each mark on to
what the source lists now rather than zeroing the count, which would come
straight back on the next check.

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

389 lines
18 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
}))"""
READ_BTN = """() => {
const button = document.getElementById('saved-read-btn');
return { disabled: button.disabled, text: button.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}
# How many videos a page comes back with, whatever was asked for: real
# sources cut pages their own way and send what they send.
size = {"n": 12}
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 + size["n"])]
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)
print("\na source that sends more than the page it was asked for")
# sxyprn answers a page of 12 with 30. Everything past the twelfth was
# never shown to the reader and was never new either -- it sat on the
# same page all along -- so none of it may end up on the badge.
size["n"] = 30
page.evaluate("""async () => {
App.videos.resetGrid();
await App.videos.loadVideos();
App.savedSearches.markViewed(App.savedSearches.getAll()[0].id);
}""")
page.wait_for_timeout(800)
saved = page.evaluate(SAVED)
c.ok("the mark holds the whole page the source sent, not the one asked for",
len(saved[0].get("seenIds") or []) == 30, str(len(saved[0].get("seenIds") or [])))
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("the same page, checked again, is not news",
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
c.ok("so the badge stays out instead of sticking on a number",
badge["hidden"] is True, str(badge))
c.ok("and nothing is counted anywhere else either",
page.evaluate("() => App.savedSearches.totalNew()") == 0)
print("\nnews on such a source still counts")
offset["at"] = offset["at"] + 5
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("five turning up is five", saved[0].get("newCount") == 5, str(saved[0].get("newCount")))
print("\nmarking the whole list read")
page.click("#saved-searches-btn")
page.wait_for_timeout(300)
c.ok("the button is offered while something is new",
page.evaluate(READ_BTN)["disabled"] is False, str(page.evaluate(READ_BTN)))
asked.clear()
page.click("#saved-read-btn")
page.wait_for_timeout(1400)
saved = page.evaluate(SAVED)
badge = page.evaluate(BADGE)
c.ok("every count goes out", all(entry.get("newCount") == 0 for entry in saved), str(saved))
c.ok("and the header count with them", badge["hidden"] is True, str(badge))
c.ok("the mark moves on to what the source lists now, so it stays out",
f"vid-{offset['at']}" in (saved[0].get("seenIds") or []) and
f"vid-{offset['at'] - 5}" not in (saved[0].get("seenIds") or []),
str((saved[0].get("seenIds") or [])[:3]))
c.ok("which took asking the source", any(b.get("query") == "sauna" for b in asked), str(asked))
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(900)
saved = page.evaluate(SAVED)
c.ok("a check straight after finds nothing new",
saved[0].get("newCount") == 0, str(saved[0].get("newCount")))
c.ok("and the button turns itself off with nothing left to clear",
page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
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())