/** * Player Events Service — regression tests * * These tests intentionally use the REAL player store (and its real derived * stores), unlike playerEvents.test.ts which mocks the store away. The bugs * covered here live in the interaction between the event handler and the * store's position/duration fields, so a mocked store cannot catch them. * * TRACES: UR-005 | DR-001 */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { get, writable } from "svelte/store"; import type { MediaItem } from "$lib/api/types"; import type { PlayerStatusEvent } from "$lib/api/bindings"; // Capture the handler that initPlayerEvents registers so we can drive events // directly, exactly as the Tauri event bridge would. let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null; vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async (_event: string, handler: any) => { registeredHandler = handler; return () => {}; }), })); vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); // The current queue item is what handleStateChanged seeds playing/paused state // from. Back it with a writable so each test can install its own item. const currentQueueItemStore = writable(null); vi.mock("$lib/stores/queue", () => ({ queue: { subscribe: vi.fn() }, currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) }, })); // Keep the player in local mode so events aren't skipped as remote. // playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts // imports `isRemoteMode` from the same module — provide both. vi.mock("$lib/stores/playbackMode", () => ({ playbackMode: { setMode: vi.fn(), initializeSessionMonitoring: vi.fn(), subscribe: (run: any) => { run({ mode: "local", isTransferring: false }); return () => {}; }, }, isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) }, })); // Remote-mode merged stores in player.ts read this; stub as no remote session. vi.mock("$lib/stores/sessions", () => ({ selectedSession: { subscribe: (run: any) => (run(null), () => {}) }, })); vi.mock("$lib/stores/sleepTimer", () => ({ sleepTimer: { set: vi.fn() }, })); vi.mock("$lib/stores/nextEpisode", () => ({ nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() }, })); vi.mock("$lib/services/preload", () => ({ preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined), })); function makeItem(overrides: Partial = {}): MediaItem { return { id: "track-1", name: "Test Track", kind: "track", durationMs: null, ...overrides, } as MediaItem; } async function fire(event: PlayerStatusEvent): Promise { if (!registeredHandler) throw new Error("handler not registered"); await registeredHandler({ payload: event }); // Let any async work inside the handler settle. await Promise.resolve(); } describe("Player Events — pause must not zero the slider duration", () => { beforeEach(async () => { // initPlayerEvents is a singleton; reset it so each test re-registers its // own handler and starts from idle player state. const { cleanupPlayerEvents } = await import("./playerEvents"); const { player } = await import("$lib/stores/player"); cleanupPlayerEvents(); player.setIdle(); vi.clearAllMocks(); registeredHandler = null; currentQueueItemStore.set(null); }); it("preserves the live duration across pause when runTimeTicks is missing", async () => { // runTimeTicks is null — the previous code recomputed duration as 0 here, // which collapsed the slider's max and snapped the thumb to the start. const item = makeItem({ durationMs: null }); currentQueueItemStore.set(item); const { initPlayerEvents } = await import("./playerEvents"); const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player"); await initPlayerEvents(); // 1. Track starts playing. await fire({ type: "state_changed", state: "playing", media_id: item.id }); // 2. Backend reports the real duration once media is loaded / position ticks. await fire({ type: "position_update", position: 42, duration: 180 }); expect(get(playbackPosition)).toBe(42); expect(get(playbackDuration)).toBe(180); // 3. User pauses. Position AND duration must survive — duration is what // drives the slider's max, so a 0 here is what caused the regression. await fire({ type: "state_changed", state: "paused", media_id: item.id }); expect(get(playbackPosition)).toBe(42); expect(get(playbackDuration)).toBe(180); // Sanity: the store is genuinely paused, not reset to idle/loading. expect(get(player).state.kind).toBe("paused"); }); it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => { // 70 seconds = 70_000 ms. const item = makeItem({ durationMs: 70_000 }); currentQueueItemStore.set(item); const { initPlayerEvents } = await import("./playerEvents"); const { playbackDuration } = await import("$lib/stores/player"); await initPlayerEvents(); // No position_update yet, so there is no live duration to prefer. await fire({ type: "state_changed", state: "paused", media_id: item.id }); expect(get(playbackDuration)).toBe(70); }); }); /** * A recoverable error is a network hiccup, not the end of playback. The handler * used to stop the player unconditionally, so a blip on wifi killed the track — * on Linux especially, where MPV's EndFile(ERROR) is the only signal a stream * died and there is no in-process controller for the backend to consult. * * TRACES: UR-004, UR-040 | DR-130 */ describe("Player Events — recoverable errors get one chance before stopping", () => { beforeEach(async () => { const { cleanupPlayerEvents } = await import("./playerEvents"); const { player } = await import("$lib/stores/player"); cleanupPlayerEvents(); player.setIdle(); vi.clearAllMocks(); registeredHandler = null; currentQueueItemStore.set(null); }); it("does not stop the player when Rust re-opened the stream", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => cmd === "player_recover_stream" ? true : null ); const { initPlayerEvents } = await import("./playerEvents"); const { player } = await import("$lib/stores/player"); await initPlayerEvents(); await fire({ type: "state_changed", state: "playing", media_id: "track-1" }); await fire({ type: "error", message: "Playback stream failed", recoverable: true }); const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd); expect(calls).toContain("player_recover_stream"); expect(calls).not.toContain("player_stop"); expect(get(player).state.kind).not.toBe("error"); }); it("stops the player when recovery declines", async () => { const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockImplementation(async (cmd: string) => cmd === "player_recover_stream" ? false : null ); const { initPlayerEvents } = await import("./playerEvents"); await initPlayerEvents(); await fire({ type: "error", message: "Playback stream failed", recoverable: true }); await Promise.resolve(); const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd); expect(calls).toContain("player_recover_stream"); expect(calls).toContain("player_stop"); }); it("does not attempt recovery for an unrecoverable error", async () => { // Android decides in its JNI callback and reports the errors it already // declined as unrecoverable, so this must not ask a second time. const { invoke } = await import("@tauri-apps/api/core"); vi.mocked(invoke).mockResolvedValue(null); const { initPlayerEvents } = await import("./playerEvents"); await initPlayerEvents(); await fire({ type: "error", message: "Decoder failed", recoverable: false }); await Promise.resolve(); const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd); expect(calls).not.toContain("player_recover_stream"); expect(calls).toContain("player_stop"); }); });