/** * Tests for `shouldShowAudioMiniPlayer` visibility invariant. * * The mini player must NOT flicker out while advancing between tracks. The * backend can momentarily report an idle/stopped state mid-transition, so the * bar stays visible as long as a queue item still exists, and only hides once * playback has genuinely ended and the queue has been cleared. * * TRACES: UR-005 | DR-009 */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { writable } from "svelte/store"; import { get } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; // Mock the dependency stores so we can drive remote mode and the current queue // item independently of the real backend listeners. const isRemoteMode = writable(false); const selectedSession = writable(null); const currentQueueItem = writable(null); vi.mock("./playbackMode", () => ({ isRemoteMode })); vi.mock("./sessions", () => ({ selectedSession })); vi.mock("./queue", () => ({ currentQueueItem })); // Imported after the mocks so player.ts picks up the mocked stores. const { player, shouldShowAudioMiniPlayer } = await import("./player"); const audioItem = { id: "a1", kind: "track" } as unknown as MediaItem; const videoItem = { id: "v1", kind: "movie" } as unknown as MediaItem; describe("shouldShowAudioMiniPlayer", () => { beforeEach(() => { isRemoteMode.set(false); selectedSession.set(null); currentQueueItem.set(null); player.setIdle(); }); it("shows while an audio track is playing", () => { player.setPlaying(audioItem, 0, 100); expect(get(shouldShowAudioMiniPlayer)).toBe(true); }); it("stays visible through a transient idle blip while still queued", () => { // A track is queued (e.g. mid-transition the backend emits idle but the // next track is already in the queue). currentQueueItem.set(audioItem); player.setIdle(); expect(get(shouldShowAudioMiniPlayer)).toBe(true); }); it("stays visible through loading/seeking transitions", () => { currentQueueItem.set(audioItem); player.setLoading(audioItem); expect(get(shouldShowAudioMiniPlayer)).toBe(true); player.setSeeking(audioItem, 10); expect(get(shouldShowAudioMiniPlayer)).toBe(true); }); it("hides once playback ends and the queue is cleared", () => { currentQueueItem.set(null); player.setIdle(); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); it("hides for video content even while playing", () => { player.setPlaying(videoItem, 0, 100); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); it("hides for video content reported only via the queue item", () => { currentQueueItem.set(videoItem); player.setIdle(); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); it("hides a video queue item that has no Jellyfin `type` but mediaType 'video'", () => { // Regression: player_play_item (every Movie/Episode via VideoPlayer) pushes // a backend PlayerMediaItem onto the queue with item_type: None but // media_type: Video → serialized with no `type` and mediaType: "video". // Without the mediaType check this slipped past the Movie/Episode guard and // surfaced the last-played video in the audio mini player after leaving // /player. const videoQueueItem = { id: "v2", mediaType: "video" } as unknown as MediaItem; currentQueueItem.set(videoQueueItem); player.setIdle(); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); it("hides for a live TV channel", () => { const liveChannelItem = { id: "c1", kind: "liveChannel" } as unknown as MediaItem; player.setPlaying(liveChannelItem, 0, 0); expect(get(shouldShowAudioMiniPlayer)).toBe(false); }); it("shows in remote mode when the session has a now-playing item", () => { isRemoteMode.set(true); selectedSession.set({ nowPlayingItem: { id: "r1" } }); expect(get(shouldShowAudioMiniPlayer)).toBe(true); }); });