feat(search): stream unsubscribed episodes directly; a subscribes in place
This commit is contained in:
@@ -67,6 +67,7 @@
|
|||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
||||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type KeybindActionName =
|
|||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "subscribe"
|
||||||
| "unsubscribe"
|
| "unsubscribe"
|
||||||
| "download"
|
| "download"
|
||||||
| "delete-download"
|
| "delete-download"
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
"move-down": () => step(1),
|
"move-down": () => step(1),
|
||||||
@@ -295,6 +305,7 @@ function SearchPage() {
|
|||||||
downloadStore.removeDownload(id).catch(() => {});
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
},
|
},
|
||||||
unsubscribe: () => unsubscribeFocused(),
|
unsubscribe: () => unsubscribeFocused(),
|
||||||
|
subscribe: () => subscribeFocused(),
|
||||||
search: () => {
|
search: () => {
|
||||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
if (depth() === 0) nav.setInputFocused(true);
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
@@ -321,8 +332,10 @@ function SearchPage() {
|
|||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
if (r.kind === "episode" && r.podcast.isSubscribed) {
|
if (r.kind === "episode") {
|
||||||
// Subscribed show's episode → stream it (matches Feed/My Shows).
|
// Any episode result streams directly — subscribed or not
|
||||||
|
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||||
|
// show's episode in place.
|
||||||
playFocusedEpisode();
|
playFocusedEpisode();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -706,7 +719,7 @@ function SearchPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<Show when={!r.podcast.isSubscribed}>
|
<Show when={!r.podcast.isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>[+] Subscribe (a)</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={r.podcast.isSubscribed}>
|
<Show when={r.podcast.isSubscribed}>
|
||||||
<text fg={theme.success}>
|
<text fg={theme.success}>
|
||||||
@@ -718,7 +731,7 @@ function SearchPage() {
|
|||||||
when={r.podcast.isSubscribed}
|
when={r.podcast.isSubscribed}
|
||||||
fallback={
|
fallback={
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
enter: subscribe · d: download · h: back to query
|
enter: play · a: subscribe · d: download · h: back to query
|
||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
|||||||
"sort",
|
"sort",
|
||||||
"toggle-hidden",
|
"toggle-hidden",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"subscribe",
|
||||||
"unsubscribe",
|
"unsubscribe",
|
||||||
"download",
|
"download",
|
||||||
"delete-download",
|
"delete-download",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
|||||||
sort: [","],
|
sort: [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
refresh: ["r"],
|
refresh: ["r"],
|
||||||
|
// a subscribes the focused show/episode result in place (x unsubscribes)
|
||||||
|
subscribe: ["a"],
|
||||||
unsubscribe: ["x"],
|
unsubscribe: ["x"],
|
||||||
// downloads
|
// downloads
|
||||||
download: ["d"],
|
download: ["d"],
|
||||||
|
|||||||
@@ -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 ────────────────
|
// ── 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)", () => {
|
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||||
withHarness(({ nav, dispatch }) => {
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
|||||||
344
tests/search-episode-actions.test.tsx
Normal file
344
tests/search-episode-actions.test.tsx
Normal file
@@ -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<SearchScope>("episode");
|
||||||
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
|
const [query, setQuery] = createSignal("");
|
||||||
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [history, setHistory] = createSignal<string[]>([]);
|
||||||
|
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<unknown> | null = null;
|
||||||
|
let feedUrl = "";
|
||||||
|
let audioUrl = "";
|
||||||
|
|
||||||
|
function feedXml(origin: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Stream Me Show</title>
|
||||||
|
<description>Test feed</description>
|
||||||
|
<item>
|
||||||
|
<title>Ep 1</title>
|
||||||
|
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||||
|
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>;
|
||||||
|
mockInput: MockInput;
|
||||||
|
nav: () => NavigationState;
|
||||||
|
keybindsReady: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mountApp(): Promise<Mounted> {
|
||||||
|
let navRef: NavigationState | null = null;
|
||||||
|
let keybindsRef: { ready: boolean } | null = null;
|
||||||
|
const StateProbe = () => {
|
||||||
|
navRef = useNavigation();
|
||||||
|
keybindsRef = useKeybinds();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const HarnessRoot = () => (
|
||||||
|
<toast.ToastProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<KeybindProvider>
|
||||||
|
<NavigationProvider>
|
||||||
|
<StateProbe />
|
||||||
|
<DialogProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<App />
|
||||||
|
<toast.Toast />
|
||||||
|
</CommandProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</KeybindProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</toast.ToastProvider>
|
||||||
|
);
|
||||||
|
const setup = await testRender(() => <HarnessRoot />, {
|
||||||
|
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<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleReady(m: Mounted): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user