diff --git a/src/config/keybinds.jsonc b/src/config/keybinds.jsonc index ce15965..6750e94 100644 --- a/src/config/keybinds.jsonc +++ b/src/config/keybinds.jsonc @@ -67,6 +67,7 @@ "sort": [","], "toggle-hidden": ["."], "refresh": ["r"], + "subscribe": ["a"], // subscribe focused show/episode result in place (Search) "unsubscribe": ["x"], // unsubscribe focused show in My Shows // ── Downloads & auto-download whitelist ─────────────────────────────────── diff --git a/src/context/KeybindContext.tsx b/src/context/KeybindContext.tsx index f27dc79..5610ec6 100644 --- a/src/context/KeybindContext.tsx +++ b/src/context/KeybindContext.tsx @@ -68,6 +68,7 @@ export type KeybindActionName = | "sort" | "toggle-hidden" | "refresh" + | "subscribe" | "unsubscribe" | "download" | "delete-download" diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7c9b57b..d5d4eac 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -264,6 +264,16 @@ function SearchPage() { } }; + /** Subscribe the focused result's show in place (episode or podcast + * result). `enter` plays episodes regardless of subscription, so an + * unsubscribed show's episode needs this explicit path. */ + const subscribeFocused = () => { + if (depth() !== 1) return; + const r = focusedResult(); + if (!r || r.podcast.isSubscribed) return; + handleSubscribe(r); + }; + // ── nav.action handler ────────────────────────────────────────────────────── const PAGE_ACTIONS: Partial void>> = { "move-down": () => step(1), @@ -295,6 +305,7 @@ function SearchPage() { downloadStore.removeDownload(id).catch(() => {}); }, unsubscribe: () => unsubscribeFocused(), + subscribe: () => subscribeFocused(), search: () => { // `s` refocuses the query input (typing mode) when on the query depth. if (depth() === 0) nav.setInputFocused(true); @@ -321,8 +332,10 @@ function SearchPage() { if (depth() === 1) { const r = focusedResult(); if (!r) return; - if (r.kind === "episode" && r.podcast.isSubscribed) { - // Subscribed show's episode → stream it (matches Feed/My Shows). + if (r.kind === "episode") { + // Any episode result streams directly — subscribed or not + // (matches Feed/My Shows). `a` subscribes an unsubscribed + // show's episode in place. playFocusedEpisode(); return; } @@ -706,7 +719,7 @@ function SearchPage() { - [+] Subscribe (enter) + [+] Subscribe (a) @@ -718,7 +731,7 @@ function SearchPage() { when={r.podcast.isSubscribed} fallback={ - enter: subscribe · d: download · h: back to query + enter: play · a: subscribe · d: download · h: back to query } > diff --git a/src/utils/dispatch.ts b/src/utils/dispatch.ts index 586fbb1..80c9075 100644 --- a/src/utils/dispatch.ts +++ b/src/utils/dispatch.ts @@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet = "sort", "toggle-hidden", "refresh", + "subscribe", "unsubscribe", "download", "delete-download", diff --git a/src/utils/keybinds-persistence.ts b/src/utils/keybinds-persistence.ts index 113c8c2..8d6eb00 100644 --- a/src/utils/keybinds-persistence.ts +++ b/src/utils/keybinds-persistence.ts @@ -65,6 +65,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = { sort: [","], "toggle-hidden": ["."], refresh: ["r"], + // a subscribes the focused show/episode result in place (x unsubscribes) + subscribe: ["a"], unsubscribe: ["x"], // downloads download: ["d"], diff --git a/tests/dispatch-keybinds.test.ts b/tests/dispatch-keybinds.test.ts index bf99afc..cd373d9 100644 --- a/tests/dispatch-keybinds.test.ts +++ b/tests/dispatch-keybinds.test.ts @@ -171,6 +171,20 @@ test("dispatch('move-up') emits nav.action on the current pane only (j/k never c }); }); +test("dispatch('subscribe') on a depth-tab current pane emits nav.action (page-local, like unsubscribe)", () => { + withHarness(({ nav, dispatch }) => { + nav.setActiveTab(TABS.SEARCH); + nav.enterTabContent(); + expect(nav.activePane()).toBe(DEPTH_CENTER_PANE); + + const events = captureNavActions(() => dispatch("subscribe")); + expect(events).toHaveLength(1); + expect(events[0].action).toBe("subscribe"); + expect(events[0].tab).toBe(TABS.SEARCH); + expect(events[0].pane).toBe(DEPTH_CENTER_PANE); + }); +}); + // ── Integration: l drills (open emit), h pops, h@0 → tab root ──────────────── test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => { withHarness(({ nav, dispatch }) => { diff --git a/tests/search-episode-actions.test.tsx b/tests/search-episode-actions.test.tsx new file mode 100644 index 0000000..daec4e0 --- /dev/null +++ b/tests/search-episode-actions.test.tsx @@ -0,0 +1,344 @@ +/** + * search-episode-actions.test.tsx — episode search results must stream + * directly, subscribed or not, and `a` must subscribe an unsubscribed show + * in place. + * + * Regression: `enter` on an unsubscribed show's episode result used to + * subscribe instead of play — there was no direct "stream unsubscribed + * episode" path (subscribing first was the only way to hear it). `enter` now + * plays every episode result (matching Feed/My Shows), and the new `subscribe` + * action (`a`, sibling of `x` unsubscribe) subscribes the focused result. + * + * Mounts the real app (sandboxed, silent audio, mocked search store) and + * drives the Search tab with the test renderer's mock keys: enter on an + * unsubscribed episode plays it without subscribing; `a` subscribes (feed + * fetched from a local server); enter then plays the now-subscribed episode. + * The search store is mocked so results are deterministic (no directory + * network calls); the feed store is real and served from a local HTTP server. + * + * 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 type { Server } from "bun"; +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 { SearchResult, SearchScope } from "../src/types/source"; +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 search store: `search` feeds the seeded results synchronously; +// markSubscribed/markUnsubscribed flip the result's flag (SearchPage renders +// it, and the real feed store still owns the actual subscription). +const [scope, setScope] = createSignal("episode"); +const [results, setResults] = createSignal([]); +const [query, setQuery] = createSignal(""); +const [isSearching, setIsSearching] = createSignal(false); +const [error, setError] = createSignal(null); +const [history, setHistory] = createSignal([]); +const flip = (id: string, feedUrl: string | undefined, subscribed: boolean) => + setResults((prev) => + prev.map((r) => + r.podcast.id === id || + (feedUrl && r.podcast.feedUrl === feedUrl) + ? { ...r, podcast: { ...r.podcast, isSubscribed: subscribed } } + : r, + ), + ); +const mockSearchStore = { + query, + isSearching, + results, + error, + history, + selectedSources: () => [] as string[], + scope, + search: async () => {}, + setQuery, + clearResults: () => setResults([]), + clearHistory: () => setHistory([]), + removeFromHistory: () => {}, + setSelectedSources: () => {}, + setScope, + markSubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, true), + markUnsubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, false), +}; +mock.module("../src/stores/search", () => ({ + useSearchStore: () => mockSearchStore, +})); + +// 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-episode-actions"); +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"); + +// Local HTTP server serving one show's RSS feed (the feed store fetches it +// when subscribing). +let server: Server | null = null; +let feedUrl = ""; +let audioUrl = ""; + +function feedXml(origin: string): string { + return ` + +Stream Me Show +Test feed + +Ep 1 +2026-08-01T00:00:00Z + + +`; +} + +function makeResult(): SearchResult { + const episode: Episode = { + id: "stream-ep-1", + podcastId: "dir-stream-me", + title: "Ep 1", + description: "", + audioUrl, + duration: 0, + pubDate: new Date("2026-08-01T00:00:00Z"), + }; + const podcast: Podcast = { + id: "dir-stream-me", + title: "Stream Me Show", + description: "Test feed", + author: "tester", + feedUrl, + lastUpdated: new Date(), + isSubscribed: false, + }; + return { + kind: "episode", + sourceId: "test", + sourceName: "Test", + podcast, + episode, + }; +} + +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(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname.endsWith(".xml")) { + return new Response(feedXml(url.origin), { + headers: { "Content-Type": "application/rss+xml" }, + }); + } + return new Response("audio bytes", { + headers: { "Content-Type": "audio/mpeg" }, + }); + }, + }); + feedUrl = `http://127.0.0.1:${server!.port}/show.xml`; + audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`; +}); + +afterAll(() => { + server?.stop(true); + rmSync(SANDBOX, { recursive: true, force: true }); +}); + +test("enter streams an unsubscribed show's episode; a subscribes it in place; enter then still plays", async () => { + const m = await mountApp(); + try { + await settleReady(m); + + // Open the Search tab (digit press retried until the router attaches). + for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) { + m.mockInput.pressKey("4"); + await m.renderOnce(); + await sleep(40); + } + expect(m.nav().activeTab()).toBe(TABS.SEARCH); + m.mockInput.pressEnter(); // open the tab's content (query depth) + await waitFor(m, () => m.nav().inputFocused(), "search tab mounted"); + + // Seed one unsubscribed-show episode result and drill to results + // (mirrors SearchPage.runSearch: set results, push the frame). + setResults([makeResult()]); + const frame: DepthFrame = { kind: "search:results", ctx: "test", focus: 0 }; + m.nav().pushDepth(frame); + m.nav().setActivePane(1); + await waitFor(m, () => m.nav().currentDepth() === 1, "results depth"); + + // enter → the episode streams; the show is NOT subscribed. + m.mockInput.pressEnter(); + await waitFor(m, () => played.length === 1, "play called on enter"); + expect(played[0].id).toBe("stream-ep-1"); + expect( + useFeedStore() + .feeds() + .some((f) => f.podcast.feedUrl === feedUrl), + ).toBe(false); + expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(false); + + // a → subscribes in place (real feed fetch against the local server). + m.mockInput.pressKey("a"); + await waitFor( + m, + () => + useFeedStore() + .feeds() + .some((f) => f.podcast.feedUrl === feedUrl), + "a subscribes the show", + ); + expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(true); + + // enter again → still streams (now under the subscribed show). + m.mockInput.pressEnter(); + await waitFor(m, () => played.length === 2, "play called after subscribe"); + expect(played[1].id).toBe("stream-ep-1"); + } finally { + m.renderer.destroy(); + } +});