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
This commit is contained in:
Simon
2026-10-19 20:15:17 +00:00
parent 6d9cc301a7
commit d5c859a1c7
6 changed files with 179 additions and 12 deletions

View File

@@ -58,6 +58,11 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row'))
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 = """() => {
@@ -83,13 +88,16 @@ def main():
# 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 + 12)]
for i in range(start, start + size["n"])]
with sync_playwright() as p:
browser = p.chromium.launch(args=[
@@ -313,6 +321,63 @@ def main():
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()