Mirrors the Feed tab's '[Fetch More]' row inside a drilled show's episode list (My Shows depth 1): shows only while the show's cache holds episodes beyond its loaded window, and advances just that show's window by 50 on Enter (or automatically at the bottom in auto mode). Same fetchMoreMode preference drives both behaviors. Adds a store-contract test for the per-feed pagination path.
144 lines
5.0 KiB
TypeScript
144 lines
5.0 KiB
TypeScript
/**
|
|
* Per-feed pagination test — the store contract behind the "[Fetch More]"
|
|
* 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:
|
|
* 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.
|
|
* 3. loadMoreEpisodes past the end is a no-op (the feed is untouched).
|
|
*/
|
|
|
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
import { mkdtempSync, rmSync } from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
|
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
|
// (their module-level init reads it).
|
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
|
|
process.env.XDG_CONFIG_HOME = configHome;
|
|
|
|
import { useFeedStore } from "../src/stores/feed";
|
|
import type { Podcast } from "../src/types/podcast";
|
|
|
|
interface ServedEpisode {
|
|
title: string;
|
|
date: string;
|
|
}
|
|
|
|
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
|
|
// feed-refresh.test.ts / feedless-subscribe.test.ts. 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(
|
|
(ep, i) => `<item>
|
|
<title>${ep.title}</title>
|
|
<pubDate>${ep.date}</pubDate>
|
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
|
</item>`,
|
|
)
|
|
.join("\n");
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss version="2.0"><channel>
|
|
<title>Paged Show</title>
|
|
<description>Pagination test feed</description>
|
|
${items}
|
|
</channel></rss>`;
|
|
}
|
|
|
|
const makePodcast = (feedUrl: string): Podcast => ({
|
|
id: feedUrl,
|
|
title: "Paged Show",
|
|
description: "Pagination test feed",
|
|
author: "tester",
|
|
feedUrl,
|
|
lastUpdated: new Date(),
|
|
isSubscribed: true,
|
|
});
|
|
|
|
beforeAll(() => {
|
|
server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
const url = new URL(req.url);
|
|
if (url.pathname.endsWith(".xml")) {
|
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
|
headers: { "Content-Type": "application/rss+xml" },
|
|
});
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
// 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 });
|
|
});
|
|
|
|
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.
|
|
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
|
title: `Ep ${60 - i}`,
|
|
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
|
}));
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged.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 more cached.
|
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
|
|
|
// One load-more covers the remaining 40 (20 + 50 >= 60).
|
|
await store.loadMoreEpisodes(id);
|
|
expect(store.getFeed(id)!.episodes.length).toBe(60);
|
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
|
|
|
// Exhausted: loadMoreEpisodes is a no-op — the feed object is untouched.
|
|
const before = store.getFeed(id)!;
|
|
await store.loadMoreEpisodes(id);
|
|
expect(store.getFeed(id)).toBe(before);
|
|
});
|
|
|
|
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.
|
|
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
|
title: `Ep ${120 - i}`,
|
|
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
|
}));
|
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
|
expect(feed).not.toBeNull();
|
|
const id = feed!.id;
|
|
addedFeedIds.push(id);
|
|
|
|
await store.loadMoreEpisodes(id);
|
|
expect(store.getFeed(id)!.episodes.length).toBe(70);
|
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
|
|
|
await store.loadMoreEpisodes(id);
|
|
expect(store.getFeed(id)!.episodes.length).toBe(120);
|
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
|
});
|