Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
162 lines
5.4 KiB
TypeScript
162 lines
5.4 KiB
TypeScript
/**
|
||
* 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<undefined> | 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) => `<item>
|
||
<title>Spin Ep ${3 - i}</title>
|
||
<pubDate>${new Date(Date.UTC(2026, 0, 1 + i)).toISOString()}</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>Spinner Show</title>
|
||
<description>spinner test feed</description>
|
||
${items}
|
||
</channel></rss>`;
|
||
}
|
||
|
||
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<Response>();
|
||
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(
|
||
() => (
|
||
<ThemeProvider mode="dark">
|
||
<NavigationProvider>
|
||
<FeedPage />
|
||
</NavigationProvider>
|
||
</ThemeProvider>
|
||
),
|
||
{ 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();
|
||
});
|