diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc index 6750e94..4813f23 100644 --- a/src/config/keybinds.jsonc +++ b/src/config/keybinds.jsonc @@ -67,7 +67,7 @@ "sort": [","], "toggle-hidden": ["."], "refresh": ["r"], - "subscribe": ["a"], // subscribe focused show/episode result in place (Search) + "subscribe": ["a"], // subscribe focused show in place (Discover/Search) "unsubscribe": ["x"], // unsubscribe focused show in My Shows // ── Downloads & auto-download whitelist ─────────────────────────────────── diff --git a/src/pages/Discover/DiscoverPage.tsx b/src/pages/Discover/DiscoverPage.tsx index d31bf32..272c2c6 100644 --- a/src/pages/Discover/DiscoverPage.tsx +++ b/src/pages/Discover/DiscoverPage.tsx @@ -5,18 +5,28 @@ * placeholder (1/5 slot kept). * depth 1 (current) — podcast results for the drilled category. Parent * pane = the categories list. - * preview — detail of the hovered item (category summary, or - * podcast detail + subscribe action). + * depth 2 (current) — episodes of the drilled show, fetched on demand + * WITHOUT subscribing. Parent pane = the results list. + * preview — detail of the hovered item (category summary, + * podcast detail, or episode detail). * * Renders entirely through ``; no bespoke 3-column flexbox JSX - * remains. `l`/Enter drills in (category → results) or subscribes (on a - * podcast); `h` pops a depth (noop at 0). j/k move only within the current - * column. Moving through categories at depth 0 updates the store's selected - * category so the preview follows. + * remains. `l`/Enter drills in (category → results → episodes); `a` + * subscribes the focused show (enter/l never subscribe — they open the + * episode list); `h` pops a depth (noop at 0). j/k move only within the + * current column. Moving through categories at depth 0 updates the store's + * selected category so the preview follows. */ import { createMemo, For, Show, onMount, onCleanup } from "solid-js"; import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover"; +import { useFeedStore } from "@/stores/feed"; +import { useDownloadStore } from "@/stores/download"; +import { useAudio } from "@/hooks/useAudio"; +import { useAudioNavStore, AudioSource } from "@/stores/audio-nav"; +import { DownloadStatus } from "@/types/episode"; +import type { Episode } from "@/types/episode"; +import type { Podcast } from "@/types/podcast"; import { format } from "date-fns"; import { useTheme } from "@/context/ThemeContext"; import { @@ -32,6 +42,7 @@ import type { KeybindActionName } from "@/context/KeybindContext"; import { PaneRow } from "@/components/PaneRow"; import { TabListPane } from "@/components/TabPanel"; import { LoadingIndicator } from "@/components/LoadingIndicator"; +import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList"; import { useScrollIntoView } from "@/hooks/useScrollIntoView"; import { useSelectionMarker } from "@/hooks/useSelectionMarker"; @@ -41,11 +52,16 @@ function DiscoverPage() { // Static: detection never changes mid-session. const nerd = supportsNerdFonts(); const discoverStore = useDiscoverStore(); + const feedStore = useFeedStore(); + const downloadStore = useDownloadStore(); + const audio = useAudio(); + const audioNav = useAudioNavStore(); const { theme } = useTheme(); const muted = () => theme.muted || theme.text; const nav = useNavigation(); const marker = useSelectionMarker(); + const stack = nav.depthStack; const depth = nav.currentDepth; const focus = (d: number = depth()) => nav.depthFocus(d); @@ -60,14 +76,37 @@ function DiscoverPage() { podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1); const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]); + // depth-2 frame ctx = the drilled podcast id (episode preview, no + // subscription). Episodes come from the discover store's session cache. + const drilledPodcastId = (): string => stack()[2]?.ctx ?? ""; + const drilledPodcast = (): Podcast | undefined => + podcasts().find((p) => p.id === drilledPodcastId()); + const episodes = createMemo(() => { + if (depth() < 2) return []; + return discoverStore.episodesForPodcast(drilledPodcastId()); + }); + const episodesLoading = () => + depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId()); + const episodesError = () => + depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined; + const focusedEpIdx = () => + episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1); + const focusedEpisode = () => episodes()[focusedEpIdx()]; + const curLen = () => - depth() === 0 ? categories().length : podcasts().length; + depth() === 0 + ? categories().length + : depth() === 1 + ? podcasts().length + : episodes().length; const ensureFocus = () => { if (categories().length > 0 && focus(0) >= categories().length) nav.setDepthFocus(categories().length - 1, 0); if (podcasts().length > 0 && focus(1) >= podcasts().length) nav.setDepthFocus(podcasts().length - 1, 1); + if (episodes().length > 0 && focus(2) >= episodes().length) + nav.setDepthFocus(episodes().length - 1, 2); }; onMount(ensureFocus); @@ -80,13 +119,56 @@ function DiscoverPage() { onMount(() => { nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => { if (depth() === 0) return categories()[i]?.id; - return podcasts()[i]?.id; + if (depth() === 1) return podcasts()[i]?.id; + return episodes()[i]?.id; }); }); // ── helpers ──────────────────────────────────────────────────────────────── const formatDate = (d: Date) => format(d, "MMM d, yyyy"); + /** The subscribed feed backing a podcast, if any (matched by directory id + * or feed URL — a Discover show may already be subscribed). */ + const feedForPodcast = (p: Podcast) => + feedStore.feeds().find( + (f) => + f.podcast.id === p.id || + (!!p.feedUrl && f.podcast.feedUrl === p.feedUrl), + ); + + const downloadLabel = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return "[Q]"; + case DownloadStatus.DOWNLOADING: + return `[${downloadStore.getDownloadProgress(id)}%]`; + case DownloadStatus.COMPLETED: + return "[DL]"; + case DownloadStatus.FAILED: + return "[ERR]"; + default: + return ""; + } + }; + const downloadColor = (id: string) => { + switch (downloadStore.getDownloadStatus(id)) { + case DownloadStatus.QUEUED: + return theme.warning; + case DownloadStatus.DOWNLOADING: + return theme.primary; + case DownloadStatus.COMPLETED: + return theme.success; + case DownloadStatus.FAILED: + return theme.error; + default: + return muted(); + } + }; + const playEpisode = (ep: Episode) => { + audio.play(ep).catch(() => {}); + audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id); + }; + // ── drill / open ─────────────────────────────────────────────────────────── function open() { if (depth() === 0) { @@ -97,9 +179,19 @@ function DiscoverPage() { nav.setActivePane(DEPTH_CENTER_PANE); return; } - if (depth() >= 1) { + if (depth() === 1) { const pod = focusedPodcast(); - if (pod) discoverStore.toggleSubscription(pod.id); + if (!pod) return; + // Drill into the show's episode list WITHOUT subscribing — `l`, + // right, and Enter open the episodes; `a` is the subscribe key. + discoverStore.openEpisodes(pod).catch(() => {}); + nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame); + nav.setActivePane(DEPTH_CENTER_PANE); + return; + } + if (depth() >= 2) { + const ep = focusedEpisode(); + if (ep) playEpisode(ep); } } @@ -115,12 +207,59 @@ function DiscoverPage() { "goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()), open: () => open(), "toggle-select": () => { - if (depth() >= 1) { + if (depth() === 1) { const pod = focusedPodcast(); if (pod) nav.toggleSelected(pod.id); } + if (depth() >= 2) { + const ep = focusedEpisode(); + if (ep) nav.toggleSelected(ep.id); + } + }, + download: () => { + if (depth() !== 2) return; + const pod = drilledPodcast(); + const ep = focusedEpisode(); + if (!pod || !ep) return; + // Under its subscribed feed when already subscribed, otherwise as + // an "unsubscribed show" download (mirrors Search). + const feed = feedForPodcast(pod); + if (feed) downloadStore.startDownload(ep, feed.id); + else downloadStore.startUnsubscribedDownload(ep, pod); + }, + "delete-download": () => { + if (depth() !== 2) return; + const ep = focusedEpisode(); + if (!ep) return; + const id = ep.id; + if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return; + downloadStore.cancelDownload(id); + downloadStore.removeDownload(id).catch(() => {}); + }, + // `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open + // the episode list, so subscribing moved off open). + subscribe: () => { + if (depth() === 1) { + const pod = focusedPodcast(); + if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id); + return; + } + if (depth() >= 2) { + const pod = drilledPodcast(); + if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id); + } + }, + unsubscribe: () => { + if (depth() !== 1) return; + const pod = focusedPodcast(); + if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id); }, refresh: () => { + if (depth() >= 2) { + const pod = drilledPodcast(); + if (pod) discoverStore.refreshEpisodes(pod).catch(() => {}); + return; + } discoverStore.refresh().catch(() => {}); }, }; @@ -161,7 +300,9 @@ function DiscoverPage() { const currentLabel = () => depth() === 0 ? "Categories" - : `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`; + : depth() === 1 + ? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}` + : `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`; // ── parent pane: previous-depth list (muted/blank at depth 0) ───────────── // Sibling blocks per depth (the known-good opentui disposal @@ -174,7 +315,7 @@ function DiscoverPage() { - = 1}> + {(cat, index) => { const lf = () => nav.depthFocus(0); @@ -203,6 +344,33 @@ function DiscoverPage() { }} + = 2}> + + {(podcast, index) => { + const lf = () => nav.depthFocus(1); + const ref = useScrollIntoView(() => index() === lf()); + return ( + + + {index() === lf() ? marker() : " "} + + + {podcast.title} + + + [+] + + + ); + }} + + ); @@ -243,7 +411,7 @@ function DiscoverPage() { {/* depth ≥1: results */} - = 1}> + 0} fallback={ @@ -304,11 +472,59 @@ function DiscoverPage() { - + + {/* depth ≥2: episodes of the drilled show (preview, no subscription) */} + = 2}> + + + + + + + + {episodesError()} + + r: retry · h: back + + + + + No episodes found. :refresh + + + 0 + } + > + + {(ep, index) => ( + nav.isSelected(ep.id)} + downloadLabel={() => downloadLabel(ep.id)} + downloadColor={() => downloadColor(ep.id)} + marker={marker} + onMouseDown={() => { + nav.setActivePane(DEPTH_CENTER_PANE); + nav.setDepthFocus(index(), 2); + }} + /> + )} + + + ); @@ -357,8 +573,8 @@ function DiscoverPage() { )} - ) : ( - // depth ≥1 preview: hovered podcast + subscribe + ) : depth() === 1 ? ( + // depth 1 preview: hovered podcast + episode-list hint by {pod().author} - ✓ Subscribed + ✓ Subscribed · x: unsubscribe - [+] Subscribe (enter) + a: subscribe @@ -398,10 +614,67 @@ function DiscoverPage() { Updated: {formatDate(pod().lastUpdated)} - enter: subscribe · h: back · r: refresh + enter/l: episodes · h: back · r: refresh )} + ) : ( + // depth ≥2 preview: hovered episode (or loading/error/empty) + <> + + + + + + + + {episodesError()} + + r: retry · h: back + + + + + No episodes found. + + + 0 && + focusedEpisode() + } + fallback={ + + No episode focused + + } + > + {(ep) => ( + ep()} + author={() => drilledPodcast()?.author} + downloadLabel={() => downloadLabel(ep().id)} + downloadColor={() => downloadColor(ep().id)} + hint={() => + `enter: play · d: download${ + downloadStore.getDownloadStatus(ep().id) !== + DownloadStatus.NONE + ? " · D: delete" + : "" + }${ + drilledPodcast()?.isSubscribed ? "" : " · a: subscribe" + } · h: back` + } + /> + )} + + ); return ( diff --git a/src/stores/discover.ts b/src/stores/discover.ts index a6e9384..cad7eee 100644 --- a/src/stores/discover.ts +++ b/src/stores/discover.ts @@ -11,6 +11,7 @@ import { createSignal } from "solid-js"; import type { Podcast } from "../types/podcast"; +import type { Episode } from "../types/episode"; import { useFeedStore } from "./feed"; export interface DiscoverCategory { @@ -42,6 +43,10 @@ const FEATURED_JSON_URL = /** Cache window for the remote featured list (24 hours) */ const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +/** Max episodes to load when previewing an unsubscribed show's episode list + * from Discover (drill-in, no subscription). Mirrors the refresh window. */ +const PREVIEW_EPISODE_LIMIT = 50; + /** Shape of a single entry in the remote JSON */ interface FeaturedEntry { id: string; @@ -90,6 +95,19 @@ export function createDiscoverStore() { const [isLoading, setIsLoading] = createSignal(false); const [podcasts, setPodcasts] = createSignal([]); + // Episodes fetched for an unsubscribed show's preview list (drill-in from + // a podcast result, no subscription). Cached per podcast id for the + // session; keyed by id so switching shows never clobbers another's list. + const [previewEpisodes, setPreviewEpisodes] = createSignal< + Record + >({}); + const [previewLoading, setPreviewLoading] = createSignal>( + new Set(), + ); + const [previewErrors, setPreviewErrors] = createSignal< + Record + >({}); + // In-memory cache timestamp for the remote manifest (within 24h, skip refetch) let cachedAt = 0; @@ -174,13 +192,64 @@ export function createDiscoverStore() { ); }; - const toggleSubscription = (podcastId: string) => { - const podcast = podcasts().find((p) => p.id === podcastId); - if (podcast?.isSubscribed) { - unsubscribe(podcastId); - } else { - subscribe(podcastId); + // ── episode preview (drill-in, no subscription) ────────────────────────── + /** Cached episode list for a previewed show (empty until first drill-in). */ + const episodesForPodcast = (podcastId: string): Episode[] => + previewEpisodes()[podcastId] ?? []; + + const isLoadingEpisodesFor = (podcastId: string): boolean => + previewLoading().has(podcastId); + + const previewError = (podcastId: string): string | undefined => + previewErrors()[podcastId]; + + /** Fetch a show's episode list WITHOUT subscribing (Discover preview). + * The list is cached per podcast id; a failed fetch records an error + * and keeps any previous cache (a retry via refreshEpisodes clears it). */ + const openEpisodes = async (podcast: Podcast): Promise => { + if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id)) + return; + if (!podcast.feedUrl) { + setPreviewErrors((prev) => ({ + ...prev, + [podcast.id]: "No RSS feed listed for this show.", + })); + return; } + setPreviewLoading((prev) => new Set(prev).add(podcast.id)); + const feedStore = useFeedStore(); + const { episodes } = await feedStore.fetchEpisodes( + podcast.feedUrl, + PREVIEW_EPISODE_LIMIT, + ); + if (episodes) { + setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes })); + } else { + setPreviewErrors((prev) => ({ + ...prev, + [podcast.id]: "Couldn't load episodes.", + })); + } + setPreviewLoading((prev) => { + const next = new Set(prev); + next.delete(podcast.id); + return next; + }); + }; + + /** Re-fetch a previewed show's episode list (`r` on the episodes depth). */ + const refreshEpisodes = async (podcast: Podcast): Promise => { + setPreviewErrors((prev) => { + const next = { ...prev }; + delete next[podcast.id]; + return next; + }); + setPreviewEpisodes((prev) => { + const next = { ...prev }; + delete next[podcast.id]; + return next; + }); + await openEpisodes(podcast); }; return { @@ -195,8 +264,14 @@ export function createDiscoverStore() { setSelectedCategory, subscribe, unsubscribe, - toggleSubscription, refresh, + + // Episode preview (drill-in, no subscription) + episodesForPodcast, + isLoadingEpisodesFor, + previewError, + openEpisodes, + refreshEpisodes, }; } diff --git a/tests/discover-episode-preview.test.tsx b/tests/discover-episode-preview.test.tsx new file mode 100644 index 0000000..0b6dad6 --- /dev/null +++ b/tests/discover-episode-preview.test.tsx @@ -0,0 +1,337 @@ +/** + * discover-episode-preview.test.tsx — Discover: `l`/right/enter on a podcast + * result must OPEN the show's episode list, NOT subscribe. + * + * Regression: `open` on a Discover podcast result (bound to `l`/right via + * `swipe-next`, and to enter) used to toggle subscription — pressing `l` on a + * show you wanted to browse subscribed it instead. `l`/right/enter now drill + * into a fetched-on-demand episode list (depth 2, no subscription), and `a` + * (the app-wide `subscribe` action) is the dedicated subscribe key. + * + * Mounts the real app (sandboxed, silent audio, mocked discover store) and + * drives the Discover tab with the test renderer's mock keys: drill category + * → podcast, `l` opens the episode list WITHOUT subscribing (feed store + * untouched, subscribe not called); `h` pops back; `a` subscribes the + * focused show; `l` then re-opens the episodes. + * + * App modules are loaded dynamically (never statically) because the sandbox + * config/data dirs must be set BEFORE they evaluate — their module-level init + * reads those env vars at import time. + */ + +import { test, expect, afterAll, beforeAll, mock } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { createSignal } from "solid-js"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { AudioControls } from "../src/hooks/useAudio"; +import type { Episode } from "../src/types/episode"; +import type { Podcast } from "../src/types/podcast"; +import type { DepthFrame, NavigationState } from "../src/context/navigation-store"; + +// Recording audio stub: `play` pushes what was streamed. Registered FIRST so +// a leaked partial useAudio mock from another file in this worker can't break +// the app mount (see tests/search-focus.test.tsx for the same hazard). +const played: Episode[] = []; +const stubAudio: AudioControls = { + isPlaying: () => false, + position: () => 0, + duration: () => 0, + volume: () => 1, + speed: () => 1, + backendName: () => "none", + error: () => null, + currentEpisode: () => null, + availablePlayers: () => [], + play: async (episode: Episode) => { + played.push(episode); + }, + load: async () => {}, + pause: async () => {}, + resume: async () => {}, + togglePlayback: async () => {}, + stop: async () => {}, + seek: async () => {}, + seekRelative: async () => {}, + setVolume: async () => {}, + setSpeed: async () => {}, + switchBackend: async () => {}, + prev: async () => {}, + next: async () => {}, +}; +mock.module("../src/hooks/useAudio", () => ({ + useAudio: () => stubAudio, +})); + +// Deterministic discover store: `openEpisodes` seeds the episode list +// synchronously (no network), `subscribe`/`unsubscribe` flip the show's flag +// and are recorded so the test can assert l/enter never subscribed. +const [selectedCategory, setSelectedCategory] = createSignal("all"); +const [isLoading, setIsLoading] = createSignal(false); +const [podcasts, setPodcasts] = createSignal([]); +const [preview, setPreview] = createSignal>({}); +const [previewLoading, setPreviewLoading] = createSignal>( + new Set(), +); +const [previewErrors, setPreviewErrors] = createSignal>( + {}, +); +const subscribeCalls: string[] = []; +const openCalls: string[] = []; +const flip = (id: string, subscribed: boolean) => + setPodcasts((prev) => + prev.map((p) => (p.id === id ? { ...p, isSubscribed: subscribed } : p)), + ); +const mockDiscoverStore = { + selectedCategory, + isLoading, + podcasts, + categories: [ + { id: "all", name: "All", icon: "" }, + { id: "technology", name: "Technology", icon: "" }, + ], + filteredPodcasts: () => { + const cat = selectedCategory(); + if (cat === "all") return podcasts(); + return podcasts().filter((p) => + (p.categories ?? []).some((c) => + c.toLowerCase().includes(cat.toLowerCase()), + ), + ); + }, + setSelectedCategory, + subscribe: (id: string) => { + subscribeCalls.push(id); + flip(id, true); + }, + unsubscribe: (id: string) => { + flip(id, false); + }, + refresh: async () => {}, + episodesForPodcast: (id: string) => preview()[id] ?? [], + isLoadingEpisodesFor: (id: string) => previewLoading().has(id), + previewError: (id: string) => previewErrors()[id], + openEpisodes: async (pod: Podcast) => { + openCalls.push(pod.id); + setPreview((prev) => ({ + ...prev, + [pod.id]: [makeEpisode(1), makeEpisode(2)], + })); + }, + refreshEpisodes: async () => {}, +}; +mock.module("../src/stores/discover", () => ({ + DISCOVER_CATEGORIES: mockDiscoverStore.categories, + useDiscoverStore: () => mockDiscoverStore, +})); + +// Sandbox BEFORE any app module evaluates — config-dir/persistence read these +// env vars at import time, so the app modules are loaded dynamically. +const SANDBOX = join(process.cwd(), ".harness", "test-discover-preview"); +mkdirSync(join(SANDBOX, "config-home"), { recursive: true }); +mkdirSync(join(SANDBOX, "data-home"), { recursive: true }); +process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home"); +process.env.XDG_DATA_HOME = join(SANDBOX, "data-home"); +process.env.PODTUI_AUDIO_BACKEND = "none"; + +const { App } = await import("../src/App"); +const { ThemeProvider } = await import("../src/context/ThemeContext"); +const toast = await import("../src/ui/toast"); +const { KeybindProvider, useKeybinds } = await import( + "../src/context/KeybindContext" +); +const { NavigationProvider, useNavigation } = await import( + "../src/context/NavigationContext" +); +const { DialogProvider } = await import("../src/ui/dialog"); +const { CommandProvider } = await import("../src/ui/command"); +const { TABS } = await import("../src/utils/navigation"); +const { useFeedStore } = await import("../src/stores/feed"); + +function makePodcast(): Podcast { + return { + id: "featured-show", + title: "Featured Show", + description: "A featured show.", + feedUrl: "https://example.test/featured.xml", + author: "tester", + categories: ["Technology"], + lastUpdated: new Date(), + isSubscribed: false, + }; +} + +function makeEpisode(n: number): Episode { + return { + id: `featured-ep-${n}`, + podcastId: "featured-show", + title: `Featured Episode ${n}`, + description: "", + audioUrl: "https://example.test/ep.mp3", + duration: 0, + pubDate: new Date(`2026-08-0${n}T00:00:00Z`), + }; +} + +type MockInput = { pressKey: (key: string) => void; pressEnter: () => void }; +type Mounted = { + renderer: { destroy: () => void }; + renderOnce: () => Promise; + mockInput: MockInput; + nav: () => NavigationState; + keybindsReady: () => boolean; +}; + +async function mountApp(): Promise { + let navRef: NavigationState | null = null; + let keybindsRef: { ready: boolean } | null = null; + const StateProbe = () => { + navRef = useNavigation(); + keybindsRef = useKeybinds(); + return null; + }; + const HarnessRoot = () => ( + + + + + + + + + + + + + + + + ); + const setup = await testRender(() => , { + width: 100, + height: 30, + useThread: false, + }); + // The test renderer intercepts stdout; the app is a TUI that writes frames + // asynchronously, so silence that interception (same as search-focus). + ( + setup.renderer as unknown as { + disableStdoutInterception?: () => void; + } + ).disableStdoutInterception?.(); + await setup.renderOnce(); + await sleep(60); + return { + renderer: setup.renderer, + renderOnce: setup.renderOnce, + mockInput: setup.mockInput, + nav: () => navRef!, + keybindsReady: () => keybindsRef?.ready ?? false, + }; +} + +function sleep(ms: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +} + +async function settleReady(m: Mounted): Promise { + for (let i = 0; i < 80; i++) { + await m.renderOnce(); + await sleep(60); + if (m.keybindsReady()) return; + } + throw new Error("keybinds never became ready"); +} + +async function waitFor( + m: Mounted, + cond: () => boolean, + what: string, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await m.renderOnce(); + await sleep(25); + } + throw new Error(`timed out waiting for: ${what}`); +} + +beforeAll(() => { + setPodcasts([makePodcast()]); +}); + +afterAll(() => { + rmSync(SANDBOX, { recursive: true, force: true }); +}); + +test("l on a podcast opens its episode list without subscribing; a subscribes", async () => { + const m = await mountApp(); + try { + await settleReady(m); + + // Open the Discover tab (digit press retried until the router attaches). + for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.DISCOVER; i++) { + m.mockInput.pressKey("3"); + await m.renderOnce(); + await sleep(40); + } + expect(m.nav().activeTab()).toBe(TABS.DISCOVER); + m.mockInput.pressEnter(); // open the tab's content (category depth) + await waitFor( + m, + () => m.nav().currentDepth() === 0 && !m.nav().atRootTab(), + "discover content mounted", + ); + + // l on the focused category drills to the podcast results (depth 1). + m.mockInput.pressKey("l"); + await waitFor(m, () => m.nav().currentDepth() === 1, "results depth"); + expect(m.nav().topFrame()?.kind).toBe("results"); + + // l on the focused podcast opens its episode list (depth 2) — the + // show must NOT be subscribed, the feed store untouched. + m.mockInput.pressKey("l"); + await waitFor(m, () => m.nav().currentDepth() === 2, "episodes depth"); + expect(m.nav().topFrame()?.kind).toBe("episodes"); + expect(m.nav().topFrame()?.ctx).toBe("featured-show"); + expect(openCalls).toEqual(["featured-show"]); + expect(subscribeCalls).toHaveLength(0); + expect(played).toHaveLength(0); + expect( + useFeedStore() + .feeds() + .some((f) => f.podcast.id === "featured-show"), + ).toBe(false); + // The seeded episode list is what the page renders at depth 2. + expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength( + 2, + ); + + // h pops back to the results (depth 1). + m.mockInput.pressKey("h"); + await waitFor(m, () => m.nav().currentDepth() === 1, "back to results"); + + // a subscribes the focused show (the dedicated subscribe key). + m.mockInput.pressKey("a"); + await waitFor( + m, + () => mockDiscoverStore.podcasts()[0]?.isSubscribed === true, + "a subscribes the show", + ); + expect(subscribeCalls).toEqual(["featured-show"]); + + // l still opens the episode list for a subscribed show (no toggle). + m.mockInput.pressKey("l"); + await waitFor(m, () => m.nav().currentDepth() === 2, "episodes re-opened"); + expect(subscribeCalls).toEqual(["featured-show"]); + expect(mockDiscoverStore.episodesForPodcast("featured-show")).toHaveLength( + 2, + ); + } finally { + m.renderer.destroy(); + } +}); diff --git a/tests/discover-store-preview.test.ts b/tests/discover-store-preview.test.ts new file mode 100644 index 0000000..c213895 --- /dev/null +++ b/tests/discover-store-preview.test.ts @@ -0,0 +1,129 @@ +/** + * discover-store-preview.test.ts — the Discover episode-preview store API. + * + * `openEpisodes` fetches a show's RSS feed WITHOUT subscribing (drill-in from + * a podcast result), caches it per podcast id for the session, records a + * per-show error on failure, and never refetches while cached or in flight. + * `refreshEpisodes` clears the cache/error and refetches. The feed store is + * mocked so the network never runs; the cache-hit/in-flight/error contracts + * are what this file defends. + */ + +import { test, expect, mock } from "bun:test"; +import type { Podcast } from "../src/types/podcast"; +import type { Episode } from "../src/types/episode"; + +const fetchCalls: string[] = []; +const mockFeedStore = { + fetchEpisodes: async (feedUrl: string, limit: number) => { + fetchCalls.push(feedUrl); + return { + episodes: [makeEpisode("ep-1")] as Episode[] | null, + coverUrl: undefined, + }; + }, +}; +mock.module("../src/stores/feed", () => ({ + useFeedStore: () => mockFeedStore, +})); + +const { useDiscoverStore } = await import("../src/stores/discover"); + +function makePodcast(overrides: Partial = {}): Podcast { + return { + id: "show-1", + title: "Show 1", + description: "", + feedUrl: "https://example.test/feed.xml", + lastUpdated: new Date(), + isSubscribed: false, + ...overrides, + }; +} + +function makeEpisode(id: string): Episode { + return { + id, + podcastId: "show-1", + title: `Ep ${id}`, + description: "", + audioUrl: "https://example.test/ep.mp3", + duration: 0, + pubDate: new Date("2026-08-01T00:00:00Z"), + }; +} + +test("openEpisodes fetches, caches, and never refetches on cache hit or in flight", async () => { + const store = useDiscoverStore(); + const pod = makePodcast(); + + expect(store.episodesForPodcast(pod.id)).toHaveLength(0); + + await store.openEpisodes(pod); + expect(fetchCalls).toEqual([pod.feedUrl]); + expect(store.episodesForPodcast(pod.id)).toHaveLength(1); + expect(store.episodesForPodcast(pod.id)[0].id).toBe("ep-1"); + expect(store.isLoadingEpisodesFor(pod.id)).toBe(false); + expect(store.previewError(pod.id)).toBeUndefined(); + + // Cache hit: second open must not refetch. + await store.openEpisodes(pod); + expect(fetchCalls).toHaveLength(1); + + // In-flight guard: a concurrent open during loading must not refetch. + const slow = mockFeedStore.fetchEpisodes; + const gate = Promise.withResolvers(); + mockFeedStore.fetchEpisodes = async (feedUrl: string, limit: number) => { + fetchCalls.push(feedUrl); + await gate.promise; + return { episodes: [makeEpisode("ep-2")] as Episode[] | null, coverUrl: undefined }; + }; + const pod2 = makePodcast({ id: "show-2", feedUrl: "https://example.test/feed2.xml" }); + const pending = store.openEpisodes(pod2); + // Loading is set synchronously before the fetch resolves. + expect(store.isLoadingEpisodesFor(pod2.id)).toBe(true); + await store.openEpisodes(pod2); // must early-return, not queue a second fetch + gate.resolve(); + await pending; + expect(fetchCalls).toEqual([pod.feedUrl, pod2.feedUrl]); + expect(store.episodesForPodcast(pod2.id)[0].id).toBe("ep-2"); + expect(store.isLoadingEpisodesFor(pod2.id)).toBe(false); + mockFeedStore.fetchEpisodes = slow; +}); + +test("openEpisodes records an error for feedless shows and failed fetches", async () => { + const store = useDiscoverStore(); + const feedless = makePodcast({ id: "show-3", feedUrl: undefined }); + + await store.openEpisodes(feedless); + expect(fetchCalls).not.toContain(feedless.id); + expect(store.previewError(feedless.id)).toBe("No RSS feed listed for this show."); + expect(store.episodesForPodcast(feedless.id)).toHaveLength(0); + + // Failed fetch (null episodes) → error recorded, nothing cached. + const original = mockFeedStore.fetchEpisodes; + mockFeedStore.fetchEpisodes = async () => ({ + episodes: null, + coverUrl: undefined, + }); + const failing = makePodcast({ id: "show-4" }); + await store.openEpisodes(failing); + expect(store.previewError(failing.id)).toBe("Couldn't load episodes."); + expect(store.episodesForPodcast(failing.id)).toHaveLength(0); + expect(store.isLoadingEpisodesFor(failing.id)).toBe(false); + mockFeedStore.fetchEpisodes = original; +}); + +test("refreshEpisodes clears the cache and error, then refetches", async () => { + const store = useDiscoverStore(); + const pod = makePodcast({ id: "show-5" }); + + await store.openEpisodes(pod); + expect(store.episodesForPodcast(pod.id)).toHaveLength(1); + const callsBefore = fetchCalls.length; + + await store.refreshEpisodes(pod); + expect(fetchCalls.length).toBe(callsBefore + 1); + expect(store.episodesForPodcast(pod.id)).toHaveLength(1); + expect(store.previewError(pod.id)).toBeUndefined(); +});