visualizer fixes

This commit is contained in:
2026-08-27 13:46:41 -04:00
parent 02c957f584
commit 895138357f
9 changed files with 196 additions and 21 deletions

View File

@@ -0,0 +1,72 @@
/**
* Unit test for feedForEpisode: resolving the feed behind an episode.
*
* The critical case is the reported regression — an iTunes show's episode has
* `podcastId` set to the RSS feed url (rss-parser:163) while the feed's
* `podcast.id` is the iTunes directory id. Those differ, so a strict
* `podcast.id` match loses the feed (and its cover, stalling Now Playing art).
*/
import { describe, expect, test } from "bun:test";
import { feedForEpisode } from "../src/utils/feed-resolve";
import { FeedVisibility } from "../src/types/feed";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
function makeFeed(id: string, feedUrl: string, title = `Show ${id}`): Feed {
return {
id,
podcast: {
id,
title,
description: "",
feedUrl,
coverUrl: `https://cover/${id}.jpg`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId: "test",
lastUpdated: new Date(),
isPinned: false,
};
}
const episode = (podcastId: string, id = "ep"): Episode => ({
id,
podcastId,
title: "Episode",
description: "",
audioUrl: "https://audio/ep.mp3",
duration: 60,
pubDate: new Date(),
});
describe("feedForEpisode", () => {
test("matches when episode.podcastId equals the feed's podcast.id", () => {
const f = makeFeed("id-a", "http://a/feed.xml");
expect(feedForEpisode([f], episode("id-a"))?.podcast.id).toBe("id-a");
});
test("matches an iTunes show by feed url (podcastId != podcast.id)", () => {
// The regression: feed.podcast.id is the directory id, podcastId the
// RSS url — a strict id match loses the feed.
const feedUrl = "http://itunes.example/feed.xml";
const f = makeFeed("itunes-1177068388", feedUrl);
const got = feedForEpisode([f], episode(feedUrl));
expect(got?.podcast.id).toBe("itunes-1177068388");
});
test("falls back to episode membership when neither id nor feedUrl match", () => {
const f = makeFeed("id-b", "http://b/feed.xml");
const ep = episode("unrelated", "ep-42");
f.episodes = [ep];
expect(feedForEpisode([f], ep)?.podcast.id).toBe("id-b");
});
test("returns undefined when no feed matches", () => {
const f = makeFeed("id-c", "http://c/feed.xml");
expect(feedForEpisode([f], episode("nowhere"))).toBeUndefined();
});
});

View File

@@ -342,6 +342,34 @@ test.skipIf(skip)(
{ timeout: 20000 },
);
// The post-resume loading gate must be "the clock MOVED from the resume
// point", not "the clock moved PAST it". Gating on `>` strands the spinner
// forever when the user seeks BACKWARD during the resume spinner (the
// classic "missed that, rewind" while a network stream re-buffers): the
// position never again exceeds the resume point, the cache serves live
// frames for the new position, and the loading state never clears.
test.skipIf(skip)(
"backward seek during the resume loading state clears it",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isLoading()).toBe(false);
// Pause, then resume against the still-covered position: spinner.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setIsPlaying(true);
await waitFor(() => viz.isLoading(), 5000);
// User seeks BACKWARD while the player re-buffers. The position is
// inside decoded coverage, so fresh bars must replace the spinner.
setPosition(1);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 3000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// ── Teardown ─────────────────────────────────────────────────────────────
afterAll(() => {