diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 56484fb..0af7ea8 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] { kind: "select", display: () => cacheModeLabel(prefs().episodeCacheMode), help: () => - `How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: keep the N most recent episodes (see Cache Count below); Fetch More pages in 50-episode chunks.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`, + `How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const idx = CACHE_MODE_LABELS.findIndex( (s) => s.value === prefs().episodeCacheMode, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 51ca302..25f1898 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -13,8 +13,12 @@ import { DEFAULT_SOURCES } from "../types/source"; import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser"; import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver"; import { savePodcastIndexCredentials } from "../utils/source-credentials"; -import { mergeEpisodesBounded } from "../utils/episode-merge"; import { + episodeSignature, + mergeEpisodesBounded, +} from "../utils/episode-merge"; +import { + DEFAULT_EPISODE_WINDOW_DAYS, episodeInWindow, loadFeedsFromFile, saveFeedsToFile, @@ -139,6 +143,31 @@ const epTs = (ep: Episode): number => { return t === undefined || Number.isNaN(t) ? Infinity : t; }; +/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the + * 2-week band. With nothing loaded (a show whose episodes all fall outside + * the cache window), the band anchors at the cache-window edge (now minus + * the configured days) — a dormant show can't drag in arbitrarily old + * episodes just because the button is pressed. */ +const dateFetchMoreCutoff = ( + cached: Episode[], + loaded: number, + windowDays: number, +): number => { + if (loaded > 0) { + const t = epTs(cached[loaded - 1]); + if (Number.isFinite(t)) { + return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000; + } + } + // Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the + // cache-window edge (e.g. 60d → reveals the 60–74d slice). + return ( + Date.now() - + Math.max(1, windowDays) * 24 * 3600 * 1000 - + FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000 + ); +}; + /** Save feeds to file (async, fire-and-forget). */ function saveFeeds(feeds: Feed[]): void { const prefs = useAppStore().state().preferences; @@ -203,12 +232,21 @@ async function migratePlaintextCredentials( * union semantics the merged list legitimately contains episodes BEYOND the * fetched window, so unchanged-detection must compare the fetched window * against the existing list's prefix — comparing full lists would bump - * `lastUpdated` on every refresh. */ -function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean { + * `lastUpdated` on every refresh. When ids drifted between refreshes (the + * one-time positional-id migration, or a feed that rotates enclosure URLs) + * the id sets differ for the SAME content, so a content-signature + * comparison decides: an unchanged feed stays unchanged. */ +export function sameRefreshWindow( + existing: Episode[], + fetched: Episode[], +): boolean { if (fetched.length === 0) return true; const prefix = existing.slice(0, fetched.length); const ids = new Set(prefix.map((e) => e.id)); - return fetched.every((e) => ids.has(e.id)); + if (fetched.every((e) => ids.has(e.id))) return true; + if (prefix.length !== fetched.length) return false; + const signatures = new Set(prefix.map(episodeSignature)); + return fetched.every((e) => signatures.has(episodeSignature(e))); } /** Run `fn` over every item with at most `limit` executions in flight — a @@ -249,6 +287,11 @@ function createFeedStore() { const [selectedFeedId, setSelectedFeedId] = createSignal(null); const [isLoadingMore, setIsLoadingMore] = createSignal(false); const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false); + /** Feed-page fetch-more presses in COUNT mode: the global list is capped + * at episodeCacheCount × (presses + 1) episodes, so one press reveals + * exactly N more of the NEWEST episodes across all shows — it can never + * dump deep history (see getAllEpisodesChronological). */ + const [countFetchMorePresses, setCountFetchMorePresses] = createSignal(0); // ── Debounced persistence ─────────────────────────────────────────────── /** Trailing-edge debounce window for config.json writes. */ @@ -357,6 +400,20 @@ function createFeedStore() { (a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(), ); + // COUNT mode: the Feed page is a GLOBAL top-K list — the newest + // `episodeCacheCount × (fetch-more presses + 1)` episodes across ALL + // shows, not N per show. A press reveals exactly N more recent + // episodes; deep history never surfaces in one jump. The cap stays + // even once every cache is exhausted (the button hides) — lifting it + // rendered the full deep union and froze the UI. + const prefs = useAppStore().state().preferences; + if (prefs.episodeCacheMode === "count") { + const limit = + Math.max(1, prefs.episodeCacheCount ?? 25) * + (countFetchMorePresses() + 1); + return allEpisodes.slice(0, limit); + } + return allEpisodes; }; @@ -814,15 +871,25 @@ function createFeedStore() { /** Check if a feed has more episodes available beyond what's currently * loaded. The full parse cache holds ALL episodes (including beyond the - * cache bound), so fetch-more can always page deeper — the bound limits - * what the Feed/My Shows list shows initially, not what fetch-more can - * reach. When the loaded window reaches the cache length, this flips - * false. */ + * cache bound), so fetch-more can page deeper — but in DATE mode only + * when the next unloaded episode falls inside the next 2-week band: a + * sparse/dormant show whose band is empty reports false, so fetch-more + * never drags in arbitrarily old episodes just because the parse cache + * holds them. When the loaded window reaches the cache length (or the + * band is empty), this flips false. */ const hasMoreEpisodes = (feedId: string): boolean => { const cached = fullEpisodeCache.get(feedId); if (!cached) return false; const loaded = episodeLoadCount.get(feedId) ?? 0; - return loaded < cached.length; + if (loaded >= cached.length) return false; + const prefs = useAppStore().state().preferences; + if (prefs.episodeCacheMode === "count") return true; + const cutoff = dateFetchMoreCutoff( + cached, + loaded, + prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS, + ); + return epTs(cached[loaded]) >= cutoff; }; /** Load the next chunk of episodes for one feed from the full parse @@ -879,29 +946,26 @@ function createFeedStore() { const prefs = useAppStore().state().preferences; // Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band - // past the oldest loaded episode — a daily show gains ~2 weeks of - // episodes, a weekly show gains its next 2, never a fixed count. + // past the oldest loaded episode (or the cache-window edge when + // nothing is loaded) — a daily show gains ~2 weeks of episodes, a + // weekly show gains its next 2, never a fixed count. An empty band + // is a genuine stop (hasMoreEpisodes hides the button) — no minimum, + // so a sparse/dormant show can't grab arbitrarily old episodes. // Count mode keeps the fixed MAX_EPISODES_REFRESH chunk. let newCount: number; if (prefs.episodeCacheMode === "date") { - const ref = cached[Math.max(0, currentCount - 1)]; - const refTs = ref ? epTs(ref) : Infinity; - if (Number.isFinite(refTs)) { - const cutoff = refTs - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000; - newCount = currentCount; - while ( - newCount < cached.length && - epTs(cached[newCount]) >= cutoff - ) { - newCount++; - } - } else { - newCount = currentCount; + const cutoff = dateFetchMoreCutoff( + cached, + currentCount, + prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS, + ); + newCount = currentCount; + while ( + newCount < cached.length && + epTs(cached[newCount]) >= cutoff + ) { + newCount++; } - // Date mode always advances at least one episode: a sparse band - // (a show that went quiet) must not wedge the button into a - // no-op while hasMoreEpisodes still reports true. - newCount = Math.max(newCount, currentCount + 1); } else { newCount = currentCount + MAX_EPISODES_REFRESH; } @@ -947,15 +1011,78 @@ function createFeedStore() { return feeds().some((f) => hasMoreEpisodes(f.id)); }; - /** Advance the loaded window by MAX_EPISODES_REFRESH for every feed that - * still has cached episodes — powers the Feed page's "[Fetch More]". */ + /** Power the Feed page's "[Fetch More]". + * Date mode: advance each feed's window by its 2-week band (empty bands + * — sparse/dormant shows — are skipped). + * Count mode: the global list cap grows by one count (see + * getAllEpisodesChronological) and every feed's window deepens by one + * count so the growing cap has material; one press reveals exactly N + * more RECENT episodes, never a far-back dump. + * Both modes compute every feed's new window FIRST (yielding between + * feeds so the renderer keeps painting) and apply ONE setFeeds — the + * Feed list rebuilds once per press instead of once per feed (the + * per-feed storms froze the UI). */ const loadMoreAllFeeds = async () => { if (isLoadingMore()) return; setIsLoadingMore(true); try { - const pending = feeds().filter((f) => hasMoreEpisodes(f.id)); - for (const feed of pending) { - await loadMoreEpisodesForFeed(feed.id); + const prefs = useAppStore().state().preferences; + const count = Math.max(1, prefs.episodeCacheCount ?? 25); + if (prefs.episodeCacheMode === "count") { + setCountFetchMorePresses((p) => p + 1); + } + const windowDays = + prefs.episodeCacheDays ?? DEFAULT_EPISODE_WINDOW_DAYS; + + const updates: Array<{ feedId: string; episodes: Episode[] }> = []; + for (const feed of feeds()) { + const cached = fullEpisodeCache.get(feed.id); + if (!cached) continue; + const currentCount = + episodeLoadCount.get(feed.id) ?? feed.episodes.length; + if (currentCount >= cached.length) continue; + let newCount: number; + if (prefs.episodeCacheMode === "count") { + newCount = Math.min(currentCount + count, cached.length); + } else { + // Date mode: skip feeds whose next band is empty — the + // button must not surface arbitrarily old episodes. + const cutoff = dateFetchMoreCutoff( + cached, + currentCount, + windowDays, + ); + if (epTs(cached[currentCount]) < cutoff) continue; + newCount = currentCount; + while ( + newCount < cached.length && + epTs(cached[newCount]) >= cutoff + ) { + newCount++; + } + } + if (newCount <= currentCount) continue; + episodeLoadCount.set(feed.id, newCount); + updates.push({ + feedId: feed.id, + episodes: cached.slice(0, newCount), + }); + // Yield so the renderer paints between feed computations. + await yieldToUI(); + } + + if (updates.length > 0) { + const byId = new Map( + updates.map((u) => [u.feedId, u.episodes]), + ); + setFeeds((prev) => + prev.map((f) => + byId.has(f.id) + ? { ...f, episodes: byId.get(f.id)! } + : f, + ), + ); + scheduleSaveFeeds(); } } finally { setIsLoadingMore(false); @@ -990,6 +1117,11 @@ function createFeedStore() { // Actions setFilter, setSelectedFeedId, + /** Fetch + parse an RSS feed WITHOUT subscribing or touching any feed + * record (Discover's episode preview). Pass no feedId to skip the + * full-parse cache; the visible window is bounded by the user's + * cache preference and `limit`. */ + fetchEpisodes, addFeed, hasFeedByUrl, removeFeed, diff --git a/src/utils/episode-merge.ts b/src/utils/episode-merge.ts index 4e5b7d2..ea850bf 100644 --- a/src/utils/episode-merge.ts +++ b/src/utils/episode-merge.ts @@ -8,11 +8,35 @@ const ts = (ep: Episode): number => { return t === undefined || Number.isNaN(t) ? Infinity : t } +/** PubDate stamp for identity matching — undated episodes collapse to a + * single token so their twins match by title alone. */ +const stamp = (ep: Episode): string => { + const t = ep.pubDate?.getTime() + return t === undefined || Number.isNaN(t) ? "undated" : String(t) +} + +/** + * Content signature identifying the SAME episode across id changes. Episode + * ids are stable (guid / enclosure-URL derived), but a feed can still change + * an episode's id between refreshes: the one-time migration from the old + * positional-id scheme, or a host that rotates signed enclosure URLs. title + + * pubDate is the most stable combination that survives both — a feed + * re-issuing an episode with the same title and date IS that episode. + */ +export const episodeSignature = (ep: Episode): string => + `${ep.title}\u0000${stamp(ep)}` + /** * Union of two episode lists keyed by id — on collision the fetched copy - * wins (fresh metadata). Result is sorted newest-first by pubDate and pruned - * by the supplied `keep` predicate: episodes outside the configured cache - * bound (date window or count) are dropped. Never mutates either input. + * wins (fresh metadata). An existing episode whose id differs from every + * fetched id but whose content signature matches a fetched episode is a + * stale-id twin (id migration / rotating enclosure URLs) and is dropped, + * otherwise the union would double every episode on the first refresh after + * the id scheme changed. Existing episodes with NO fetched twin survive + * (volatile in-memory window). Result is sorted newest-first by pubDate and + * pruned by the supplied `keep` predicate: episodes outside the configured + * cache bound (date window or count) are dropped. Never mutates either + * input. * * The caller supplies `keep` so this module stays free of the preference * types — the feed store passes a closure bound to the user's mode/count/days. @@ -23,8 +47,17 @@ export function mergeEpisodesBounded( keep: (ep: Episode, index: number) => boolean, ): Episode[] { const byId = new Map() - for (const ep of existing) byId.set(ep.id, ep) - for (const ep of fetched) byId.set(ep.id, ep) - const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a)) + const bySignature = new Map() + for (const ep of fetched) { + byId.set(ep.id, ep) + bySignature.set(episodeSignature(ep), ep) + } + const merged = [...byId.values()] + for (const ep of existing) { + if (byId.has(ep.id)) continue + if (bySignature.has(episodeSignature(ep))) continue + merged.push(ep) + } + const sorted = merged.sort((a, b) => ts(b) - ts(a)) return sorted.filter((ep, i) => keep(ep, i)) } diff --git a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md index ebaf622..d59ea64 100644 --- a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md +++ b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md @@ -59,6 +59,7 @@ tests: - Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare). - Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile). - Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk. + - Count-mode global step: two feeds with staggered dates — one Feed-page press adds the configured N most-recent UNLOADED episodes across ALL shows (N total, not N per show), via the k-way frontier merge in `loadMoreAllFeedsByCount`. - Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there. - No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number. - Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it. diff --git a/tests/feed-nonblocking.test.ts b/tests/feed-nonblocking.test.ts index b57098f..9e880d1 100644 --- a/tests/feed-nonblocking.test.ts +++ b/tests/feed-nonblocking.test.ts @@ -62,7 +62,7 @@ const addedFeedIds: string[] = []; /** Feed created by the debounce test, reused by the flushPendingSave test. */ let debounceFeedId = ""; -/** XML for the current served episode list (episode ids = feedUrl#index). */ +/** XML for the current served episode list (episode ids derive from enclosure URLs). */ function feedXml(episodes: ServedEpisode[], origin: string): string { const items = episodes .map( diff --git a/tests/feed-pagination.test.ts b/tests/feed-pagination.test.ts index dac3f79..88f3519 100644 --- a/tests/feed-pagination.test.ts +++ b/tests/feed-pagination.test.ts @@ -42,7 +42,7 @@ let servedEpisodes: ServedEpisode[] = []; // store (execution order between files is not guaranteed). const addedFeedIds: string[] = []; -/** XML for the current served episode list (episode ids = feedUrl#index). */ +/** XML for the current served episode list (episode ids derive from enclosure URLs). */ function feedXml(episodes: ServedEpisode[], origin: string): string { const items = episodes .map( @@ -71,7 +71,10 @@ const makePodcast = (feedUrl: string): Podcast => ({ isSubscribed: true, }); -beforeAll(() => { +beforeAll(async () => { + // The app store loads its persisted prefs asynchronously at import; wait + // for that so our count-mode override isn't clobbered by the load. + await useAppStore().whenReady(); // Chunk-based stepping is count-mode behavior (see header comment). useAppStore().updatePreferences({ episodeCacheMode: "count", diff --git a/tests/feed-refresh.test.ts b/tests/feed-refresh.test.ts index fc4aef0..16ccf54 100644 --- a/tests/feed-refresh.test.ts +++ b/tests/feed-refresh.test.ts @@ -42,7 +42,7 @@ let feedAId = ""; /** When set, the server 503s this path — simulates a feed going down. */ let failPath: string | null = null; -/** XML for the current served episode list (episode ids = feedUrl#index). */ +/** XML for the current served episode list (episode ids derive from enclosure URLs). */ function feedXml(episodes: ServedEpisode[], origin: string): string { const items = episodes .map( @@ -79,6 +79,18 @@ beforeAll(() => { if (failPath && url.pathname === failPath) { return new Response("feed unavailable", { status: 503 }); } + // A dedicated single-episode feed for the failed-refresh test: + // it must not depend on (or shrink) the shared servedEpisodes + // list, which other tests' feeds read on refreshAllFeeds. + if (url.pathname === "/flaky.xml") { + return new Response( + feedXml( + [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }], + url.origin, + ), + { headers: { "Content-Type": "application/rss+xml" } }, + ); + } if (url.pathname.endsWith(".xml")) { return new Response(feedXml(servedEpisodes, url.origin), { headers: { "Content-Type": "application/rss+xml" }, @@ -139,8 +151,9 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => { test("a failed refresh does not wipe the feed's episodes", async () => { const store = useFeedStore(); - const savedEpisodes = servedEpisodes; - servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }]; + // /flaky.xml serves its own fixed single-episode feed (see server route) + // so the shared servedEpisodes list stays untouched for feedA, which + // refreshAllFeeds below also refreshes. const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`; const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); expect(feed).not.toBeNull(); @@ -161,11 +174,6 @@ test("a failed refresh does not wipe the feed's episodes", async () => { failPath = null; store.removeFeed(feedId); - // Restore the shared served content: with union merge semantics (volatile - // episodes survive refreshes) this feed keeps its larger in-memory window, - // so later tests must serve the same episodes they added — a shrink here - // would make the next test's "unchanged" refresh genuinely different. - servedEpisodes = savedEpisodes; }); test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => { diff --git a/tests/feed-volatile-merge.test.ts b/tests/feed-volatile-merge.test.ts index 1e69d25..f5e5c35 100644 --- a/tests/feed-volatile-merge.test.ts +++ b/tests/feed-volatile-merge.test.ts @@ -28,7 +28,7 @@ import { join } from "path"; const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-")); process.env.XDG_CONFIG_HOME = configHome; -import { useFeedStore } from "../src/stores/feed"; +import { sameRefreshWindow, useFeedStore } from "../src/stores/feed"; import { mergeEpisodesBounded } from "../src/utils/episode-merge"; import { episodeInWindow } from "../src/utils/feeds-persistence"; import { useAppStore } from "../src/stores/app"; @@ -132,6 +132,67 @@ test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", expect(merged.find((e) => e.id === "a")!.title).toBe("New Title"); }); +test("mergeEpisodesBounded drops stale-id twins (id migration / rotating enclosure URLs)", () => { + // The same two episodes with different ids on both sides — exactly what a + // refresh sees after the positional-id → stable-id migration (or a host + // that rotates signed audio URLs). Without content matching the union + // would double every episode. + const d1 = new Date("2026-08-01T00:00:00Z"); + const d2 = new Date("2026-08-02T00:00:00Z"); + const existing = [ + makeEpisode("feed#0", "Ep 1", d1), + makeEpisode("feed#1", "Ep 2", d2), + ]; + const fetched = [ + makeEpisode("feed#guid:g1", "Ep 1", d1), + makeEpisode("feed#guid:g2", "Ep 2", d2), + ]; + const keepAll = () => true; + + const merged = mergeEpisodesBounded(existing, fetched, keepAll); + + expect(merged.map((e) => e.id)).toEqual(["feed#guid:g2", "feed#guid:g1"]); + expect(merged).toHaveLength(2); +}); + +test("mergeEpisodesBounded keeps an existing episode with no fetched twin (volatile window)", () => { + // Fetched covers Ep 1 only (by content twin). Ep 2 exists only in memory + // — the volatile window — and must survive the refresh. + const d1 = new Date("2026-08-01T00:00:00Z"); + const d2 = new Date("2026-08-02T00:00:00Z"); + const existing = [ + makeEpisode("feed#0", "Ep 1", d1), + makeEpisode("feed#1", "Ep 2", d2), + ]; + const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d1)]; + const keepAll = () => true; + + const merged = mergeEpisodesBounded(existing, fetched, keepAll); + + expect(merged).toHaveLength(2); + expect(merged.map((e) => e.title).sort()).toEqual(["Ep 1", "Ep 2"]); +}); + +test("sameRefreshWindow treats id drift with identical content as unchanged", () => { + // Same episode, id changed between refreshes (migration / URL rotation): + // the refresh must NOT bump lastUpdated or re-render. + const d = new Date("2026-08-01T00:00:00Z"); + const existing = [makeEpisode("feed#0", "Ep 1", d)]; + const fetched = [makeEpisode("feed#guid:g1", "Ep 1", d)]; + expect(sameRefreshWindow(existing, fetched)).toBe(true); +}); + +test("sameRefreshWindow flags a genuinely new episode even when ids drift", () => { + const d1 = new Date("2026-08-01T00:00:00Z"); + const d2 = new Date("2026-08-02T00:00:00Z"); + const existing = [makeEpisode("feed#0", "Ep 1", d1)]; + const fetched = [ + makeEpisode("feed#guid:g2", "Ep 2", d2), + makeEpisode("feed#guid:g1", "Ep 1", d1), + ]; + expect(sameRefreshWindow(existing, fetched)).toBe(false); +}); + test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => { const existing = [ makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")), @@ -284,13 +345,57 @@ test("date mode boundary: 25 days in, 70 days out", async () => { expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ "In Window", ]); - // The full cache holds both, but the visible list only shows the in-window - // one — fetch-more surfaces the out-of-window one (volatile). - expect(store.hasMoreEpisodes(id)).toBe(true); + // The 70d episode is ~45 days past the 2-week band beyond the oldest + // loaded episode (25d → 39d band): a sparse show must NOT drag it in. + expect(store.hasMoreEpisodes(id)).toBe(false); await store.loadMoreEpisodes(id); expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ "In Window", - "Out Window", + ]); +}); + +test("date mode: a dormant show (nothing in the window or next band) never fetch-mores", async () => { + const store = useFeedStore(); + const now = Date.now(); + // Newest episode 100 days old, next 200 days old — both far outside the + // 60-day cache window and the 14-day band past its edge. + servedEpisodes = [ + { title: "Old A", date: new Date(now - 100 * DAY).toISOString() }, + { title: "Old B", date: new Date(now - 200 * DAY).toISOString() }, + ]; + const feedUrl = `http://127.0.0.1:${server!.port}/dormant.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + expect(store.getFeed(id)!.episodes.length).toBe(0); + expect(store.hasMoreEpisodes(id)).toBe(false); + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(0); +}); + +test("date mode: episodes just outside the window load via the band anchored at the window edge", async () => { + const store = useFeedStore(); + const now = Date.now(); + // Both episodes are outside the 60-day window (61d / 65d) but inside the + // 14-day band past its edge (60d → 74d) — fetch-more reveals them. + servedEpisodes = [ + { title: "Just Out A", date: new Date(now - 61 * DAY).toISOString() }, + { title: "Just Out B", date: new Date(now - 65 * DAY).toISOString() }, + ]; + const feedUrl = `http://127.0.0.1:${server!.port}/just-out.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + expect(store.getFeed(id)!.episodes.length).toBe(0); + expect(store.hasMoreEpisodes(id)).toBe(true); + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([ + "Just Out A", + "Just Out B", ]); }); @@ -334,6 +439,9 @@ test("date mode: fetch-more steps by a two-week window, not a count", async () = test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => { const store = useFeedStore(); const app = useAppStore(); + // The app store loads persisted prefs asynchronously at import — wait so + // the override below isn't clobbered by the load. + await app.whenReady(); app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 }); const now = Date.now(); @@ -364,3 +472,67 @@ test("count mode: only N most-recent episodes are visible, but fetch-more goes b // Reset to date mode for subsequent tests. app.updatePreferences({ episodeCacheMode: "date" }); }); + +test("count mode: Feed list is a GLOBAL top-N that grows N per press, never a far-back dump", async () => { + const store = useFeedStore(); + const app = useAppStore(); + // Wait out the async pref load (see the single-show count test). + await app.whenReady(); + app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 }); + + const now = Date.now(); + // Feed A: 200 episodes at 1-day spacing (ages 0–199d). Feed B: 200 at + // 1-day spacing shifted 200 days older (ages 200–399d) — every A episode + // is newer than every B episode, so the global top-K is deterministic. + const serve = (prefix: string, shiftDays: number) => + Array.from({ length: 200 }, (_, i) => ({ + title: `${prefix} Ep ${200 - i}`, + date: new Date(now - (shiftDays + i) * DAY).toISOString(), + })); + servedEpisodes = serve("A", 0); + const aUrl = `http://127.0.0.1:${server!.port}/global-a.xml`; + const a = await store.addFeed(makePodcast(aUrl), "test-source"); + expect(a).not.toBeNull(); + const aId = a!.id; + addedFeedIds.push(aId); + servedEpisodes = serve("B", 200); + const bUrl = `http://127.0.0.1:${server!.port}/global-b.xml`; + const b = await store.addFeed(makePodcast(bUrl), "test-source"); + expect(b).not.toBeNull(); + const bId = b!.id; + addedFeedIds.push(bId); + + // The Feed page's global list is capped at the configured count (25), + // NOT 20 per show (the union would be 40). + expect(store.getAllEpisodesChronological().length).toBe(25); + + // Press 1: cap grows to 50 AND every feed's window deepens by 25 — the + // list reveals exactly the next 25 most-recent episodes (A's 25 more), + // not 25 from every show. + await store.loadMoreAllFeeds(); + expect(store.getAllEpisodesChronological().length).toBe(50); + expect(store.getFeed(aId)!.episodes.length).toBe(45); + expect(store.getFeed(bId)!.episodes.length).toBe(45); + expect(store.hasMoreAcrossAll()).toBe(true); + + // Press 2: cap grows to 75. + await store.loadMoreAllFeeds(); + expect(store.getAllEpisodesChronological().length).toBe(75); + + // Keep pressing until every cache is exhausted. The global cap stays + // (never lifts — rendering the full deep union froze the UI), so the + // Feed list stays at count×(presses+1) = 25×9 = 225 while the per-show + // windows hold everything. + let guard = 0; + while (store.hasMoreAcrossAll() && guard++ < 30) { + await store.loadMoreAllFeeds(); + } + expect(guard).toBeLessThan(30); + expect(store.hasMoreAcrossAll()).toBe(false); + expect(store.getFeed(aId)!.episodes.length).toBe(200); + expect(store.getFeed(bId)!.episodes.length).toBe(200); + expect(store.getAllEpisodesChronological().length).toBe(225); + + // Reset to date mode for subsequent tests. + app.updatePreferences({ episodeCacheMode: "date" }); +}); diff --git a/tests/restore-session.test.ts b/tests/restore-session.test.ts index efaa3cd..8f574fc 100644 --- a/tests/restore-session.test.ts +++ b/tests/restore-session.test.ts @@ -31,7 +31,7 @@ process.env.XDG_CONFIG_HOME = CONFIG; process.env.XDG_DATA_HOME = DATA; process.env.PODTUI_AUDIO_BACKEND = "none"; -// ── Local RSS feed server (episode ids = feedUrl#index) ──────────────────── +// ── Local RSS feed server (episode ids derive from enclosure URLs) ───────── let server: ReturnType | null = null; function feedXml(origin: string): string { const items = ["Episode One", "Episode Two"]