/** * FeedPage refresh spinner — while feeds are being fetched (manual `r` and * the background refresh timer both route through refreshAllFeeds → * isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode * list, horizontally centered in the current pane. * * The refresh is left in flight on purpose (the test server delays its * response) so the loading state is visible in the captured frame. */ import { test, expect, beforeAll, afterAll } from "bun:test"; import type { Server } from "bun"; 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) and silence the audio backend. const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-")); process.env.XDG_CONFIG_HOME = configHome; process.env.PODTUI_AUDIO_BACKEND = "none"; import { testRender } from "@opentui/solid"; import { ThemeProvider } from "../src/context/ThemeContext"; import { NavigationProvider } from "../src/context/NavigationContext"; import { FeedPage } from "../src/pages/Feed/FeedPage"; import { useFeedStore } from "../src/stores/feed"; import type { Podcast } from "../src/types/podcast"; // The LoadingIndicator glyph cycle. const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/; type Frame = { cols: number; lines: { spans: { text: string }[] }[] }; const frameLines = (f: Frame): string[] => f.lines.map((l) => l.spans.map((s) => s.text).join("")); let server: Server | null = null; /** Response delay (ms) for the next fetch — 0 during setup, >0 while the * refresh is in flight so the loading state is observable. */ let delayMs = 0; let feedUrl = ""; let feedId = ""; /** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them. */ function feedXml(origin: string): string { const items = Array.from({ length: 3 }, (_, i) => ` Spin Ep ${3 - i} ${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()} `).join("\n"); return ` Spinner Show spinner test feed ${items} `; } beforeAll(async () => { server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url); if (!url.pathname.endsWith(".xml")) { return new Response("not found", { status: 404 }); } const { promise, resolve } = Promise.withResolvers(); setTimeout( () => resolve( new Response(feedXml(url.origin), { headers: { "Content-Type": "application/rss+xml" }, }), ), delayMs, ); return promise; }, }); const podcast: Podcast = { id: "", title: "Spinner Show", description: "spinner test feed", author: "tester", feedUrl: "", lastUpdated: new Date(), isSubscribed: true, }; feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`; const store = useFeedStore(); const feed = await store.addFeed( { ...podcast, feedUrl }, "test-source", ); feedId = feed!.id; }); afterAll(async () => { const store = useFeedStore(); store.removeFeed(feedId); server?.stop(true); rmSync(configHome, { recursive: true, force: true }); }); test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => { const store = useFeedStore(); const setup = await testRender( () => ( ), { width: 100, height: 30, useThread: false }, ); // Settle until the episode list is mounted. let lines: string[] | null = null; for (let i = 0; i < 40 && !lines; i++) { await setup.renderOnce(); const ls = frameLines(setup.captureSpans() as unknown as Frame); if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls; else await new Promise((r) => setTimeout(r, 50)); } if (!lines) throw new Error("FeedPage did not render episodes before timeout"); // Kick off a refresh and leave it in flight: isLoadingFeeds flips true // synchronously, so the very next frame shows the spinner. delayMs = 400; const refreshing = store.refreshAllFeeds(); await setup.renderOnce(); const loading = frameLines(setup.captureSpans() as unknown as Frame); // Locate the spinner row and the current pane's borders ("│" columns; // only the current pane is bordered in PaneRow). const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l)); expect(spinnerRow).toBeGreaterThan(-1); const spinnerCol = loading[spinnerRow].search(SPINNER_RE); const borderCols = loading .map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : [])) .flat() .filter((x) => x >= 0); const paneLeft = Math.min(...borderCols); const paneRight = Math.max(...borderCols); const paneCenter = (paneLeft + paneRight) / 2; expect(paneLeft).toBeGreaterThan(0); // borders actually found // Bottom of the list: below the last episode row. const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep")); expect(spinnerRow).toBeGreaterThan(lastEpRow); // Horizontally centered in the current pane (not left-padded). expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8); // Let the refresh finish so teardown is clean. delayMs = 0; await refreshing; setup.renderer.destroy(); });