feat(feed): make episode cache bound user-configurable (date window or count)
Add episodeCacheMode/count/days preferences (default: date, 60 days). Apply the bound when reading instead of writing, so a preference change takes effect without a refetch; the full parse cache stays intact so fetch-more can page beyond the bound. Thread the window through load/saveFeedsToFile and update tests and task docs.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
.harness/
|
||||
.ralpi
|
||||
notes.md
|
||||
# pygienium run-state and check artifacts
|
||||
.pygienium/
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
* driven by the Shell router via nav.action.
|
||||
*
|
||||
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||
* • Auto Download — master toggle (default: off)
|
||||
* • Auto Download Count — X most recent episodes per show (default: 2,
|
||||
* any positive integer — type it in the editor)
|
||||
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
|
||||
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
||||
* field over subscribed shows; suggestions toggle
|
||||
* in/out with Space (j/k to move, Esc to browse).
|
||||
* • Episode Cache Mode — date or count bound for the episode list
|
||||
* (default: date)
|
||||
* • Episode Cache Count — N most recent episodes when mode is count
|
||||
* (default: 25)
|
||||
* • Episode Cache Days — rolling N-day window when mode is date
|
||||
* (default: 60)
|
||||
*/
|
||||
|
||||
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
import { on } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import { TABS } from "@/utils/navigation";
|
||||
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
|
||||
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||
import type { Feed } from "@/types/feed";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "whitelist", label: "Whitelist" },
|
||||
];
|
||||
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "count", label: "Count" },
|
||||
];
|
||||
|
||||
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||
}
|
||||
|
||||
function scopeLabel(scope: AutoDownloadScope): string {
|
||||
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||
@@ -205,6 +215,79 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "episodeCacheMode",
|
||||
label: "Episode Cache Mode",
|
||||
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).\nCount: keep the N most recent episodes (see Cache Count below).\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,
|
||||
);
|
||||
const next =
|
||||
CACHE_MODE_LABELS[
|
||||
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||
].value;
|
||||
app.updatePreferences({ episodeCacheMode: next });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "episodeCacheCount",
|
||||
label: "Episode Cache Count",
|
||||
kind: "number",
|
||||
display: () =>
|
||||
prefs().episodeCacheMode === "count"
|
||||
? `${prefs().episodeCacheCount} eps`
|
||||
: "(date mode)",
|
||||
help: () =>
|
||||
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||
app.updatePreferences({ episodeCacheCount: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Episode Cache Count"
|
||||
value={() => prefs().episodeCacheCount}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
episodeCacheCount: Math.max(1, n),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "episodeCacheDays",
|
||||
label: "Episode Cache Days",
|
||||
kind: "number",
|
||||
display: () =>
|
||||
prefs().episodeCacheMode === "date"
|
||||
? `${prefs().episodeCacheDays} days`
|
||||
: "(count mode)",
|
||||
help: () =>
|
||||
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||
cycle: (dir) => {
|
||||
const next = Math.min(
|
||||
365,
|
||||
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||
);
|
||||
app.updatePreferences({ episodeCacheDays: next });
|
||||
},
|
||||
renderEditor: () => (
|
||||
<NumberInputEditor
|
||||
label="Episode Cache Days"
|
||||
value={() => prefs().episodeCacheDays}
|
||||
commit={(n) => {
|
||||
app.updatePreferences({
|
||||
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fetchMore",
|
||||
label: "Fetch More",
|
||||
|
||||
@@ -45,6 +45,9 @@ const defaultPreferences: UserPreferences = {
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "auto",
|
||||
refreshIntervalMinutes: 30,
|
||||
episodeCacheMode: "date",
|
||||
episodeCacheCount: 25,
|
||||
episodeCacheDays: 60,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
|
||||
@@ -13,8 +13,9 @@ 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 { mergeEpisodes } from "../utils/episode-merge";
|
||||
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||
import {
|
||||
episodeInWindow,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
loadSourcesFromFile,
|
||||
@@ -31,11 +32,6 @@ const MAX_EPISODES_REFRESH = 50;
|
||||
/** Max episodes to fetch on initial subscribe */
|
||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||
|
||||
/** Per-feed bound on both the cached parse results and the merged in-memory
|
||||
* window; 500 covers years of a weekly show's history while capping a
|
||||
* 20-subscription install at 10k episodes. */
|
||||
export const MAX_EPISODES_IN_MEMORY = 500;
|
||||
|
||||
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||
* the background refresh loop. */
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
@@ -93,15 +89,42 @@ const parseEpisodesIncremental = async (
|
||||
return episodes;
|
||||
};
|
||||
|
||||
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
||||
/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the
|
||||
* full parse — the bound (count or date) is applied when reading, not when
|
||||
* writing, so changing the preference takes effect without a refetch.
|
||||
* Fetch-more reads beyond the bound from this cache (volatile only — the
|
||||
* cache itself is never extended by fetch-more). */
|
||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||
|
||||
/** Track how many episodes are currently loaded per feed */
|
||||
/** Track how many episodes are currently loaded (visible) per feed. The
|
||||
* loaded window grows via fetch-more but never exceeds what the cache
|
||||
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||
const episodeLoadCount = new Map<string, number>();
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget) */
|
||||
/** Read the episode cache bound from preferences: a closure that decides
|
||||
* whether the episode at `index` (0 = newest, after sort) is kept. */
|
||||
function episodeKeepFn(prefs: {
|
||||
episodeCacheMode: "date" | "count";
|
||||
episodeCacheCount: number;
|
||||
episodeCacheDays: number;
|
||||
}): (ep: Episode, index: number) => boolean {
|
||||
const now = new Date();
|
||||
if (prefs.episodeCacheMode === "count") {
|
||||
const count = Math.max(1, prefs.episodeCacheCount);
|
||||
return (_ep: Episode, index: number) => index < count;
|
||||
}
|
||||
const days = Math.max(1, prefs.episodeCacheDays);
|
||||
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||
}
|
||||
|
||||
/** Save feeds to file (async, fire-and-forget). */
|
||||
function saveFeeds(feeds: Feed[]): void {
|
||||
saveFeedsToFile(feeds);
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const days =
|
||||
prefs.episodeCacheMode === "date"
|
||||
? Math.max(1, prefs.episodeCacheDays)
|
||||
: undefined;
|
||||
saveFeedsToFile(feeds, days);
|
||||
}
|
||||
|
||||
/** Save sources to file (async, fire-and-forget) */
|
||||
@@ -325,13 +348,15 @@ function createFeedStore() {
|
||||
);
|
||||
};
|
||||
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
||||
* Returns NULL when the feed could not be fetched (network error, non-OK
|
||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes.
|
||||
* Also returns the channel-level artwork so callers can backfill a feed's
|
||||
* coverUrl (subscribe + refresh). Null episodes on any failure — a
|
||||
* failed fetch must not look like an empty feed, or the store would wipe
|
||||
* a subscribed show's episodes. */
|
||||
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
|
||||
* episodes in fullEpisodeCache. The visible episodes returned are
|
||||
* bounded by the user's cache preference (count or date); the full
|
||||
* cache survives so fetch-more can page beyond the bound without a
|
||||
* refetch (volatile only — the cache is never extended by fetch-more).
|
||||
* Returns NULL episodes on any failure — a failed fetch must not look
|
||||
* like an empty feed, or the store would wipe a subscribed show's
|
||||
* episodes. Also returns the channel-level artwork so callers can
|
||||
* backfill a feed's coverUrl (subscribe + refresh). */
|
||||
const fetchEpisodes = async (
|
||||
feedUrl: string,
|
||||
limit: number,
|
||||
@@ -356,14 +381,28 @@ function createFeedStore() {
|
||||
await parseEpisodesIncremental(xml, feedUrl),
|
||||
);
|
||||
|
||||
// Cache all parsed episodes for pagination
|
||||
if (feedId) {
|
||||
fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY));
|
||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
||||
// Cache the FULL parse — the bound is applied when reading,
|
||||
// not when writing, so a preference change takes effect
|
||||
// without a refetch.
|
||||
fullEpisodeCache.set(feedId, allEpisodes);
|
||||
}
|
||||
|
||||
// Bound the visible window by the user's cache preference.
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const keep = episodeKeepFn(prefs);
|
||||
const bounded = allEpisodes.filter((ep, i) => keep(ep, i));
|
||||
const visible = bounded.slice(0, limit);
|
||||
|
||||
if (feedId) {
|
||||
// Track how many episodes are visible — the bounded window,
|
||||
// not the full parse. hasMoreEpisodes compares this to the
|
||||
// full cache length to decide if fetch-more can page deeper.
|
||||
episodeLoadCount.set(feedId, visible.length);
|
||||
}
|
||||
|
||||
return {
|
||||
episodes: allEpisodes.slice(0, limit),
|
||||
episodes: visible,
|
||||
coverUrl: parseChannelCoverUrl(xml),
|
||||
};
|
||||
} catch {
|
||||
@@ -470,19 +509,22 @@ function createFeedStore() {
|
||||
* only when the content actually changed (see sameRefreshWindow). The
|
||||
* fetched window is MERGED into the existing episodes (fetched copy wins
|
||||
* on id collision) so a refresh never shrinks the in-memory list; the
|
||||
* union is capped at MAX_EPISODES_IN_MEMORY. Returns the ORIGINAL array
|
||||
* reference when nothing changed so callers skip persistence entirely —
|
||||
* a refresh that fetched identical episodes must not re-sort the
|
||||
* "updated" view. */
|
||||
* union is pruned by the user's cache bound (count or date) so episodes
|
||||
* outside the bound fall out of the visible list on the next refresh.
|
||||
* Returns the ORIGINAL array reference when nothing changed so callers
|
||||
* skip persistence entirely — a refresh that fetched identical episodes
|
||||
* must not re-sort the "updated" view. */
|
||||
const applyRefreshedEpisodes = (
|
||||
prev: Feed[],
|
||||
feedId: string,
|
||||
episodes: Episode[],
|
||||
): Feed[] => {
|
||||
let changed = false;
|
||||
const prefs = useAppStore().state().preferences;
|
||||
const keep = episodeKeepFn(prefs);
|
||||
const updated = prev.map((f) => {
|
||||
if (f.id !== feedId) return f;
|
||||
const merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY);
|
||||
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
|
||||
if (sameRefreshWindow(f.episodes, episodes)) return f;
|
||||
changed = true;
|
||||
return { ...f, episodes: merged, lastUpdated: new Date() };
|
||||
@@ -574,7 +616,11 @@ function createFeedStore() {
|
||||
const { promise: feedsReady, resolve: resolveFeedsReady } =
|
||||
Promise.withResolvers<void>();
|
||||
(async () => {
|
||||
const loadedFeeds = await loadFeedsFromFile();
|
||||
const loadedFeeds = await loadFeedsFromFile(
|
||||
useAppStore().state().preferences.episodeCacheMode === "date"
|
||||
? Math.max(1, useAppStore().state().preferences.episodeCacheDays)
|
||||
: undefined,
|
||||
);
|
||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||
resolveFeedsReady();
|
||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||
@@ -752,7 +798,12 @@ function createFeedStore() {
|
||||
return id ? getFeed(id) : undefined;
|
||||
};
|
||||
|
||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
||||
/** 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. */
|
||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||
const cached = fullEpisodeCache.get(feedId);
|
||||
if (!cached) return false;
|
||||
@@ -760,7 +811,13 @@ function createFeedStore() {
|
||||
return loaded < cached.length;
|
||||
};
|
||||
|
||||
/** Load the next chunk of episodes for one feed from the cache.
|
||||
/** Load the next chunk of episodes for one feed from the full parse
|
||||
* cache — VOLATILE only: the episodes surfaced beyond the cache bound
|
||||
* are held in the feed's in-memory episode list (so the user can browse
|
||||
* them) but are NOT written back to fullEpisodeCache (the cache keeps
|
||||
* its original bounded shape; these episodes vanish on the next
|
||||
* refresh or restart). The cache is populated by fetchEpisodes/refresh;
|
||||
* a cold cache (post-restart) triggers a refetch here.
|
||||
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||
@@ -769,7 +826,8 @@ function createFeedStore() {
|
||||
|
||||
let cached = fullEpisodeCache.get(feedId);
|
||||
|
||||
// If no cache, re-fetch and parse the full feed
|
||||
// If no cache, re-fetch and parse the full feed (cold path after a
|
||||
// restart). The cache holds the FULL parse — no bound applied here.
|
||||
if (!cached) {
|
||||
try {
|
||||
const response = await fetch(feed.podcast.feedUrl, {
|
||||
@@ -789,14 +847,12 @@ function createFeedStore() {
|
||||
// untouched rather than throwing out of loadMoreEpisodes.
|
||||
return;
|
||||
}
|
||||
// Cold-refetch parse output is unsorted; sort and cap it so the
|
||||
// cache and the pagination window stay newest-first and bounded.
|
||||
// Cold-refetch parse output is unsorted; sort it newest-first.
|
||||
// Yield before the sync sort (the parse already yielded before
|
||||
// this point, but the sort of potentially hundreds of episodes
|
||||
// is its own sync block).
|
||||
await yieldToUI();
|
||||
cached = sortEpisodesReverseChronological(cached);
|
||||
cached = cached.slice(0, MAX_EPISODES_IN_MEMORY);
|
||||
fullEpisodeCache.set(feedId, cached);
|
||||
// Set current load count to match what's already displayed
|
||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||
@@ -810,6 +866,9 @@ function createFeedStore() {
|
||||
|
||||
if (newCount <= currentCount) return; // nothing more to load
|
||||
|
||||
// Advance the loaded window — volatile: the episodes beyond the cache
|
||||
// bound are held in feed.episodes (visible) but the cache itself is
|
||||
// NOT extended. episodeLoadCount tracks the volatile window size.
|
||||
episodeLoadCount.set(feedId, newCount);
|
||||
const episodes = cached.slice(0, newCount);
|
||||
|
||||
|
||||
@@ -96,6 +96,11 @@ export type FetchMoreMode = "manual" | "auto";
|
||||
/** Which shows the auto-download setting applies to (default: all). */
|
||||
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
||||
|
||||
/** How the episode cache (the Feed / My Shows list + the pagination cache)
|
||||
* is bounded: by a rolling date window or by a count of most-recent
|
||||
* episodes (default: date). */
|
||||
export type EpisodeCacheMode = "date" | "count";
|
||||
|
||||
export type UserPreferences = {
|
||||
showExplicit: boolean;
|
||||
autoDownload: boolean;
|
||||
@@ -111,6 +116,12 @@ export type UserPreferences = {
|
||||
fetchMoreMode: FetchMoreMode;
|
||||
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||
refreshIntervalMinutes: number;
|
||||
/** How the episode list cache is bounded — by date or by count (default: date). */
|
||||
episodeCacheMode: EpisodeCacheMode;
|
||||
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
|
||||
episodeCacheCount: number;
|
||||
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
|
||||
episodeCacheDays: number;
|
||||
};
|
||||
|
||||
export type AppState = {
|
||||
|
||||
@@ -49,6 +49,9 @@ const defaultPreferences: UserPreferences = {
|
||||
autoJumpToPlayer: true,
|
||||
fetchMoreMode: "auto",
|
||||
refreshIntervalMinutes: 30,
|
||||
episodeCacheMode: "date",
|
||||
episodeCacheCount: 25,
|
||||
episodeCacheDays: 60,
|
||||
};
|
||||
|
||||
const defaultState: AppState = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Episode } from "../types/episode"
|
||||
import type { Episode } from "../types/episode";
|
||||
|
||||
/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST
|
||||
* (Infinity) so undated episodes float to the top instead of dropping into
|
||||
@@ -10,17 +10,21 @@ const ts = (ep: Episode): number => {
|
||||
|
||||
/**
|
||||
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||
* wins (fresh metadata). Result is sorted newest-first by pubDate and capped
|
||||
* at `cap` entries (oldest dropped). Never mutates either input.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function mergeEpisodes(
|
||||
export function mergeEpisodesBounded(
|
||||
existing: Episode[],
|
||||
fetched: Episode[],
|
||||
cap: number,
|
||||
keep: (ep: Episode, index: number) => boolean,
|
||||
): Episode[] {
|
||||
const byId = new Map<string, Episode>()
|
||||
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))
|
||||
return sorted.slice(0, cap)
|
||||
return sorted.filter((ep, i) => keep(ep, i))
|
||||
}
|
||||
|
||||
@@ -10,22 +10,35 @@ import type { Episode } from "../types/episode";
|
||||
import type { Feed } from "../types/feed";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
|
||||
/** Retention window for persisted episodes: older episodes are dropped when
|
||||
* feeds are written to config.json unless they are completed downloads. */
|
||||
export const PERSISTED_WINDOW_DAYS = 30;
|
||||
/** Default episode lifecycle window in days — used when no preference is
|
||||
* configured (legacy configs, first launch). The actual bound is the user's
|
||||
* episodeCacheDays preference; this is just the fail-safe default. */
|
||||
export const DEFAULT_EPISODE_WINDOW_DAYS = 60;
|
||||
|
||||
/** True when an episode may be persisted: it is a completed download, or its
|
||||
* pubDate is missing/invalid (fail-safe: never drop an undatable episode),
|
||||
* or it falls inside the retention window. */
|
||||
/** True when an episode falls inside a rolling date window of `days` days.
|
||||
* A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an
|
||||
* undatable episode) — the volatile cache must agree with
|
||||
* episodeIsPersistable so an episode the persistence layer retains can
|
||||
* never be silently pruned from the list. */
|
||||
export function episodeInWindow(
|
||||
ep: Episode,
|
||||
now: Date,
|
||||
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
): boolean {
|
||||
const t = ep.pubDate?.getTime();
|
||||
if (!t || Number.isNaN(t)) return true;
|
||||
return t >= now.getTime() - days * 24 * 3600 * 1000;
|
||||
}
|
||||
|
||||
/** True when an episode may be persisted: a completed download, or it falls
|
||||
* inside the lifecycle window (undatable episodes always kept). */
|
||||
export function episodeIsPersistable(
|
||||
ep: Episode,
|
||||
downloadedIds: Set<string>,
|
||||
now: Date,
|
||||
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
): boolean {
|
||||
if (downloadedIds.has(ep.id)) return true;
|
||||
const t = ep.pubDate?.getTime();
|
||||
if (!t || Number.isNaN(t)) return true;
|
||||
return t >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000;
|
||||
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
|
||||
}
|
||||
|
||||
/** Episode ids of completed downloads, read from downloads.json. In-flight
|
||||
@@ -70,12 +83,13 @@ function reviveDates(feed: Feed): Feed {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load feeds from config.json, pruning episodes outside the retention
|
||||
* window (completed downloads always kept). When anything was pruned, the
|
||||
* pruned list is rewritten to config.json (startup cleanup for legacy
|
||||
* configs). The read path is awaited so the returned value is deterministic. */
|
||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||
export async function loadFeedsFromFile(
|
||||
windowDays?: number,
|
||||
): Promise<Feed[]> {
|
||||
try {
|
||||
const cfg = await loadConfig();
|
||||
if (!Array.isArray(cfg.feeds)) return [];
|
||||
@@ -85,14 +99,14 @@ export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||
let prunedAny = false;
|
||||
const pruned = feeds.map((f) => {
|
||||
const kept = f.episodes.filter((ep) =>
|
||||
episodeIsPersistable(ep, downloadedIds, now),
|
||||
episodeIsPersistable(ep, downloadedIds, now, windowDays),
|
||||
);
|
||||
if (kept.length !== f.episodes.length) prunedAny = true;
|
||||
return { ...f, episodes: kept };
|
||||
});
|
||||
if (prunedAny) {
|
||||
// Fire-and-forget cleanup rewrite of the legacy config.
|
||||
saveFeedsToFile(pruned);
|
||||
saveFeedsToFile(pruned, windowDays);
|
||||
}
|
||||
return pruned;
|
||||
} catch {
|
||||
@@ -104,14 +118,14 @@ export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||
* (completed downloads always kept). Fire-and-forget: the prune reads
|
||||
* downloads.json asynchronously, then enqueues the write. On any error the
|
||||
* UNPRUNED feeds are saved instead, so data is never lost. */
|
||||
export function saveFeedsToFile(feeds: Feed[]): void {
|
||||
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
||||
(async () => {
|
||||
try {
|
||||
const downloadedIds = await readDownloadedEpisodeIds();
|
||||
const pruned = feeds.map((f) => ({
|
||||
...f,
|
||||
episodes: f.episodes.filter((ep) =>
|
||||
episodeIsPersistable(ep, downloadedIds, new Date()),
|
||||
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
||||
),
|
||||
}));
|
||||
updateConfig({ feeds: pruned });
|
||||
@@ -120,7 +134,6 @@ export function saveFeedsToFile(feeds: Feed[]): void {
|
||||
}
|
||||
})().catch(() => {});
|
||||
}
|
||||
|
||||
/** Load sources from config.json */
|
||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||
try {
|
||||
@@ -131,7 +144,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save sources to config.json */
|
||||
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||
|
||||
@@ -22,11 +22,9 @@ background (read this before touching code):
|
||||
deliverables:
|
||||
|
||||
- `src/utils/feeds-persistence.ts`:
|
||||
- New exported constant `PERSISTED_WINDOW_DAYS = 30`.
|
||||
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when:
|
||||
- `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode), OR
|
||||
- `ep.pubDate.getTime() >= now.getTime() - PERSISTED_WINDOW_DAYS * 24 * 3600 * 1000`, OR
|
||||
- `downloadedIds.has(ep.id)`.
|
||||
- New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02).
|
||||
- New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`.
|
||||
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`.
|
||||
- New (module-private) async helper `readDownloadedEpisodeIds(): Promise<Set<string>>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment.
|
||||
- `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path).
|
||||
- `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 02. Merge refreshes against the volatile in-memory episode window with bounded per-feed caches
|
||||
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
|
||||
|
||||
meta:
|
||||
id: bounded-feed-lifecycle-02
|
||||
@@ -9,7 +9,7 @@ meta:
|
||||
|
||||
objective:
|
||||
|
||||
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention so memory stops growing unbounded (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
|
||||
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (`EPISODE_WINDOW_DAYS`, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
|
||||
|
||||
background (read this before touching code):
|
||||
|
||||
@@ -17,52 +17,58 @@ background (read this before touching code):
|
||||
- `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`.
|
||||
- `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing).
|
||||
- `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`.
|
||||
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: old episodes stay browsable until the app exits, fetched refreshes never shrink the list.
|
||||
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh.
|
||||
- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons).
|
||||
|
||||
deliverables:
|
||||
|
||||
- New `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
|
||||
- `mergeEpisodes(existing: Episode[], fetched: Episode[], cap: number): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; truncated to `cap` entries (the OLDEST are dropped — after sorting, a plain `.slice(0, cap)`).
|
||||
- `src/utils/feeds-persistence.ts` (the canonical window owner):
|
||||
- Rename the retention constant to `EPISODE_WINDOW_DAYS = 30` — it now bounds the volatile cache/list as well as persistence.
|
||||
- New exported `episodeInWindow(ep: Episode, now: Date): boolean` — `pubDate >= now - EPISODE_WINDOW_DAYS`; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`.
|
||||
- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
|
||||
- `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (out-of-window episodes dropped, undated kept). No count cap — the bound is the date.
|
||||
- Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper).
|
||||
- `src/stores/feed.ts`:
|
||||
- New constant `MAX_EPISODES_IN_MEMORY = 500` (comment: per-feed bound on both the cached parse results and the merged in-memory window; 500 covers years of a weekly show's history while capping a 20-subscription install at 10k episodes).
|
||||
- `fetchEpisodes`: cap what goes into `fullEpisodeCache` — `fullEpisodeCache.set(feedId, allEpisodes.slice(0, MAX_EPISODES_IN_MEMORY))` (the array is already sorted newest-first via `sortEpisodesReverseChronological`). The LIMIT window returned to callers is unchanged.
|
||||
- Delete `MAX_EPISODES_IN_MEMORY` — no episode-count bound anywhere.
|
||||
- `fetchEpisodes`: window-filter the parsed feed (`allEpisodes.filter(ep => episodeInWindow(ep, new Date()))`) BEFORE caching and returning: `fullEpisodeCache.set(feedId, windowed)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound.
|
||||
- `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics:
|
||||
- Compute `merged = mergeEpisodes(f.episodes, episodes, MAX_EPISODES_IN_MEMORY)`.
|
||||
- Compute `merged = mergeEpisodesInWindow(f.episodes, episodes, new Date())`.
|
||||
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
||||
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
||||
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||
- `loadMoreEpisodesForFeed`: cap the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before capping); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the capped cache.
|
||||
- `tests/feed-volatile-merge.test.ts` (new) — see tests section.
|
||||
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
||||
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||
|
||||
steps:
|
||||
|
||||
1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness).
|
||||
2. Write `src/utils/episode-merge.ts` with `mergeEpisodes`.
|
||||
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodes` in `applyRefreshedEpisodes`; cap `fullEpisodeCache` writes in `fetchEpisodes` and `loadMoreEpisodesForFeed`; add `MAX_EPISODES_IN_MEMORY`.
|
||||
2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS` → `EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`.
|
||||
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`.
|
||||
4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
|
||||
5. Write the new tests, run, then full suite + lint.
|
||||
|
||||
tests:
|
||||
|
||||
- New `tests/feed-volatile-merge.test.ts`:
|
||||
- Pure unit (Arrange–Act–Assert) for `mergeEpisodes`:
|
||||
- Pure unit (Arrange–Act–Assert) for `mergeEpisodesInWindow(existing, fetched, now)`:
|
||||
- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
|
||||
- union of disjoint lists sorted by `pubDate` desc.
|
||||
- cap trimming drops the oldest: `cap=2`, three episodes spanning three days → the two newest survive.
|
||||
- window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
|
||||
- input arrays not mutated.
|
||||
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
||||
- 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).
|
||||
- Bounded cache: serve 600 items (generate programmatically), refresh, then `hasMoreEpisodes` grows only to the cap: loop `loadMoreEpisodes` until it returns false and assert total loaded ≤ `MAX_EPISODES_IN_MEMORY` (import the constant from the store module if exported, else assert `=== 500`).
|
||||
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
|
||||
- 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.
|
||||
- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`.
|
||||
|
||||
acceptance_criteria:
|
||||
|
||||
- A refresh never removes an episode that was visible before the refresh during the same session.
|
||||
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
|
||||
- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved).
|
||||
- Per-feed cached/parsed episodes never exceed `MAX_EPISODES_IN_MEMORY`; `loadMore` stops (hasMore → false) at the cap.
|
||||
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and can surface over-30-day episodes in volatile memory.
|
||||
- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days is cached or loadable, and everything inside is (no count ceiling).
|
||||
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
|
||||
- `bun test` full suite passes; `bun run lint` clean.
|
||||
|
||||
validation:
|
||||
@@ -70,10 +76,10 @@ validation:
|
||||
- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts`
|
||||
- `bun test`
|
||||
- `bun run lint`
|
||||
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the deep pages stay; quit and relaunch — deep (over-30-day) pages are gone from the list but fetch-more brings them back.
|
||||
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
|
||||
|
||||
notes:
|
||||
|
||||
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
|
||||
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the cap belongs at the two write sites named in deliverables, not in a wrapper.
|
||||
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the window filter belongs at the two write sites named in deliverables, not in a wrapper.
|
||||
- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today.
|
||||
|
||||
@@ -21,7 +21,7 @@ Dependencies
|
||||
Exit criteria
|
||||
|
||||
- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch.
|
||||
- In-memory retention is capped per feed; episodes aged out of the persisted window remain browsable within the session and are re-fetchable via fetch-more after a restart.
|
||||
- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling).
|
||||
- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout.
|
||||
- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise.
|
||||
- `bun test` and `bun run lint` pass.
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||
* page's row.
|
||||
*
|
||||
* addFeed caches the FULL parsed feed while exposing only the first
|
||||
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
|
||||
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
|
||||
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
|
||||
* pins:
|
||||
* addFeed caches every episode inside the lifecycle window (the last
|
||||
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
||||
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
||||
* reports when the cache holds more than the loaded window;
|
||||
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
||||
* chunks until it is exhausted. This pins:
|
||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||
@@ -27,6 +28,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
@@ -94,10 +97,12 @@ afterAll(() => {
|
||||
|
||||
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
||||
const store = useFeedStore();
|
||||
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
|
||||
// 60 episodes: 20 shown at subscribe, 40 held back in the cache. All
|
||||
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||
// cacheable — the cache bound is the date window, not a count.
|
||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||
title: `Ep ${60 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
@@ -123,9 +128,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops",
|
||||
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
||||
const store = useFeedStore();
|
||||
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
||||
// All inside the lifecycle window (5h apart = 25 days).
|
||||
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
||||
title: `Ep ${120 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
|
||||
@@ -41,11 +41,12 @@ let delayMs = 0;
|
||||
let feedUrl = "";
|
||||
let feedId = "";
|
||||
|
||||
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */
|
||||
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
|
||||
* Dated inside the lifecycle window (1–3 days ago) so all three render. */
|
||||
function feedXml(origin: string): string {
|
||||
const items = Array.from({ length: 3 }, (_, i) => `<item>
|
||||
<title>Spin Ep ${3 - i}</title>
|
||||
<pubDate>${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()}</pubDate>
|
||||
<pubDate>${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
@@ -193,10 +195,12 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
|
||||
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
|
||||
const store = useFeedStore();
|
||||
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
|
||||
// monolithic parse would complete without yielding at all.
|
||||
// monolithic parse would complete without yielding at all. All dated
|
||||
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||
// cacheable and the window assertions below hold.
|
||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||
title: `Ep ${60 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
|
||||
*
|
||||
* Pins the persistence contract:
|
||||
* 1. saveFeedsToFile never writes an episode older than PERSISTED_WINDOW_DAYS
|
||||
* 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS
|
||||
* unless its id is a completed download in downloads.json.
|
||||
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
|
||||
* rewrites config.json when it pruned anything.
|
||||
@@ -27,7 +27,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import {
|
||||
PERSISTED_WINDOW_DAYS,
|
||||
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||
episodeIsPersistable,
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
@@ -134,18 +134,18 @@ afterAll(() => {
|
||||
|
||||
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
|
||||
|
||||
test("episodeIsPersistable drops a 40-day-old episode that is not downloaded", () => {
|
||||
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
|
||||
const ep = makeEpisode({
|
||||
id: "old-plain-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
pubDate: new Date(Date.now() - 70 * DAY),
|
||||
});
|
||||
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
|
||||
});
|
||||
|
||||
test("episodeIsPersistable keeps a 40-day-old episode whose id is a completed download", () => {
|
||||
test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => {
|
||||
const ep = makeEpisode({
|
||||
id: "old-downloaded-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
pubDate: new Date(Date.now() - 70 * DAY),
|
||||
});
|
||||
expect(
|
||||
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
|
||||
@@ -165,8 +165,8 @@ test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
|
||||
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("PERSISTED_WINDOW_DAYS is 30", () => {
|
||||
expect(PERSISTED_WINDOW_DAYS).toBe(30);
|
||||
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
|
||||
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
||||
});
|
||||
|
||||
// ── Save path: retention window applied with completed-download exemption ──
|
||||
@@ -199,11 +199,11 @@ test("saveFeedsToFile prunes over-window episodes but keeps completed downloads"
|
||||
}),
|
||||
makeEpisode({
|
||||
id: "old-plain-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
pubDate: new Date(Date.now() - 70 * DAY),
|
||||
}),
|
||||
makeEpisode({
|
||||
id: "old-downloaded-id",
|
||||
pubDate: new Date(Date.now() - 40 * DAY),
|
||||
pubDate: new Date(Date.now() - 70 * DAY),
|
||||
}),
|
||||
]);
|
||||
saveFeedsToFile([feed]);
|
||||
@@ -249,7 +249,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a
|
||||
description: "",
|
||||
audioUrl: "https://example.com/audio/old-a.mp3",
|
||||
duration: 60,
|
||||
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
|
||||
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "old-b",
|
||||
@@ -258,7 +258,7 @@ test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", a
|
||||
description: "",
|
||||
audioUrl: "https://example.com/audio/old-b.mp3",
|
||||
duration: 60,
|
||||
pubDate: new Date(Date.now() - 40 * DAY).toISOString(),
|
||||
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||
},
|
||||
],
|
||||
visibility: "public",
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
/**
|
||||
* Volatile in-memory episode merge + bounded cache tests.
|
||||
* Configurable episode cache + volatile merge tests.
|
||||
*
|
||||
* Two behaviors from the bounded-feed-lifecycle work:
|
||||
* 1. mergeEpisodes unions refreshed episodes with what's already in memory
|
||||
* (the fetched copy wins on id collision), so a refresh never shrinks
|
||||
* the session's visible window; the union is capped per feed at
|
||||
* MAX_EPISODES_IN_MEMORY.
|
||||
* 2. The per-feed parse cache is capped at MAX_EPISODES_IN_MEMORY, so
|
||||
* loadMoreEpisodes can never surface more than the cap and
|
||||
* hasMoreEpisodes flips false there.
|
||||
* The episode list cache (what the Feed and My Shows pages show) is bounded by
|
||||
* the user's preference: a date window (default 60 days) or a count (default
|
||||
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
|
||||
* bound from that cache (volatile — never written back). These tests pin:
|
||||
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
|
||||
* (fetched copy wins on id collision) and prunes by the supplied keep
|
||||
* predicate (count or date). Undated episodes are always kept.
|
||||
* 2. The store bounds the visible list by the configured mode, but the
|
||||
* full parse cache survives — fetch-more pages beyond the bound.
|
||||
* 3. Refresh merge never shrinks the in-memory list except via the bound.
|
||||
*
|
||||
* Unchanged-refresh detection compares the fetched window against the
|
||||
* corresponding PREFIX of the merged list (sameRefreshWindow) — comparing
|
||||
* full lists would bump lastUpdated on every refresh because the merged list
|
||||
* legitimately holds episodes beyond the fetched window.
|
||||
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
|
||||
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
|
||||
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
|
||||
* boundary is pinned with relative pubDates, never by moving the clock.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||
@@ -26,11 +28,16 @@ import { join } from "path";
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { MAX_EPISODES_IN_MEMORY, useFeedStore } from "../src/stores/feed";
|
||||
import { mergeEpisodes } from "../src/utils/episode-merge";
|
||||
import { 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";
|
||||
import type { Episode } from "../src/types/episode";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const HOUR = 3600 * 1000;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
@@ -38,13 +45,8 @@ interface ServedEpisode {
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||
// the other feed test files. Track the feeds we add and remove them in
|
||||
// afterAll so whichever file runs next sees a pristine store (execution
|
||||
// order between files is not guaranteed).
|
||||
const addedFeedIds: string[] = [];
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
@@ -104,16 +106,17 @@ beforeEach(() => {
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── mergeEpisodes unit tests ─────────────────────────────────────────────
|
||||
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
|
||||
|
||||
test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => {
|
||||
const NOW = new Date("2026-08-10T00:00:00Z");
|
||||
|
||||
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
|
||||
const existing = [
|
||||
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
|
||||
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
|
||||
@@ -121,14 +124,15 @@ test("mergeEpisodes dedupes on id collision and keeps the fetched copy", () => {
|
||||
const fetched = [
|
||||
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
const keepAll = () => true;
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 10);
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||
});
|
||||
|
||||
test("mergeEpisodes unions disjoint lists sorted newest-first", () => {
|
||||
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||
const existing = [
|
||||
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
@@ -136,13 +140,14 @@ test("mergeEpisodes unions disjoint lists sorted newest-first", () => {
|
||||
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
|
||||
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
const keepAll = () => true;
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 10);
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("mergeEpisodes drops the oldest episodes past the cap", () => {
|
||||
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
|
||||
const existing = [
|
||||
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
|
||||
];
|
||||
@@ -150,13 +155,32 @@ test("mergeEpisodes drops the oldest episodes past the cap", () => {
|
||||
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
|
||||
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
|
||||
];
|
||||
const keepCount2 = (_ep: Episode, i: number) => i < 2;
|
||||
|
||||
const merged = mergeEpisodes(existing, fetched, 2);
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
|
||||
});
|
||||
|
||||
test("mergeEpisodes never mutates its inputs", () => {
|
||||
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
|
||||
const existing = [
|
||||
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
|
||||
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
|
||||
makeEpisode("undated", "Undated", new Date(NaN)),
|
||||
];
|
||||
const fetched = [
|
||||
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
|
||||
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
|
||||
];
|
||||
// 30-day window from NOW (2026-08-10)
|
||||
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
|
||||
|
||||
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
|
||||
|
||||
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
|
||||
});
|
||||
|
||||
test("mergeEpisodesBounded never mutates its inputs", () => {
|
||||
const existing = [
|
||||
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
|
||||
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
|
||||
@@ -167,18 +191,15 @@ test("mergeEpisodes never mutates its inputs", () => {
|
||||
];
|
||||
const existingIds = existing.map((e) => e.id);
|
||||
const existingTitles = existing.map((e) => e.title);
|
||||
const fetchedIds = fetched.map((e) => e.id);
|
||||
const fetchedTitles = fetched.map((e) => e.title);
|
||||
const keepAll = () => true;
|
||||
|
||||
mergeEpisodes(existing, fetched, 10);
|
||||
mergeEpisodesBounded(existing, fetched, keepAll);
|
||||
|
||||
expect(existing.map((e) => e.id)).toEqual(existingIds);
|
||||
expect(existing.map((e) => e.title)).toEqual(existingTitles);
|
||||
expect(fetched.map((e) => e.id)).toEqual(fetchedIds);
|
||||
expect(fetched.map((e) => e.title)).toEqual(fetchedTitles);
|
||||
});
|
||||
|
||||
// ── store integration ────────────────────────────────────────────────────
|
||||
// ── store integration (default date mode, 60-day window) ─────────────────
|
||||
|
||||
test("refresh merges new episodes without removing the volatile window", async () => {
|
||||
const store = useFeedStore();
|
||||
@@ -195,8 +216,6 @@ test("refresh merges new episodes without removing the volatile window", async (
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(3);
|
||||
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
|
||||
|
||||
// The feed now serves the same 3 episodes plus 2 newer ones (new ids at
|
||||
// item indices 3 and 4).
|
||||
servedEpisodes = [
|
||||
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||
@@ -221,32 +240,92 @@ test("refresh merges new episodes without removing the volatile window", async (
|
||||
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
|
||||
});
|
||||
|
||||
test("cached episodes are capped at MAX_EPISODES_IN_MEMORY", async () => {
|
||||
test("date mode: episodes outside the 60-day window never enter the list", async () => {
|
||||
const store = useFeedStore();
|
||||
const now = Date.now();
|
||||
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
|
||||
// window, so all 600 are cached and loadable (no count ceiling).
|
||||
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
|
||||
title: `Ep ${600 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
date: new Date(now - i * 2 * HOUR).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/huge.xml`;
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
// Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with 480 more cached.
|
||||
// Subscribe window (20) with more cached.
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||
|
||||
// Load in MAX_EPISODES_REFRESH chunks until the cache is exhausted.
|
||||
let maxLoaded = 0;
|
||||
// Load everything — the cache holds all 600 (date mode keeps them all).
|
||||
let iterations = 0;
|
||||
while (store.hasMoreEpisodes(id) && iterations < 20) {
|
||||
await store.loadMoreEpisodes(id);
|
||||
maxLoaded = Math.max(maxLoaded, store.getFeed(id)!.episodes.length);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
expect(iterations).toBeLessThan(20);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(MAX_EPISODES_IN_MEMORY);
|
||||
expect(maxLoaded).toBeLessThanOrEqual(MAX_EPISODES_IN_MEMORY);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(600);
|
||||
});
|
||||
|
||||
test("date mode boundary: 25 days in, 70 days out", async () => {
|
||||
const store = useFeedStore();
|
||||
const now = Date.now();
|
||||
servedEpisodes = [
|
||||
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
|
||||
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.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.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);
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||
"In Window",
|
||||
"Out Window",
|
||||
]);
|
||||
});
|
||||
|
||||
// ── count mode ────────────────────────────────────────────────────────────
|
||||
|
||||
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||
const store = useFeedStore();
|
||||
const app = useAppStore();
|
||||
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||
|
||||
const now = Date.now();
|
||||
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
|
||||
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
|
||||
title: `Ep ${50 - i}`,
|
||||
date: new Date(now - i * HOUR).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
// Subscribe window (20), but the cache holds all 50 — count mode only
|
||||
// bounds the visible list (25), but the full parse cache is unbounded.
|
||||
// The subscribe window returns min(20, 25) = 20.
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
|
||||
// Fetch more: the visible list grows beyond the count bound — these
|
||||
// episodes are volatile (held in feed.episodes, not extending the cache).
|
||||
while (store.hasMoreEpisodes(id)) {
|
||||
await store.loadMoreEpisodes(id);
|
||||
}
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(50);
|
||||
|
||||
// Reset to date mode for subsequent tests.
|
||||
app.updatePreferences({ episodeCacheMode: "date" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user