// Tests for playback mode store // TRACES: UR-010 | DR-037 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { get } from "svelte/store"; // Mock Tauri invoke const mockInvoke = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => mockInvoke(...args), })); // Mock the sessions store. `selectedSession` is read via svelte's `get()`, which // calls subscribe and synchronously receives the current value; tests set // `currentSelectedSession` to control what the store yields. const mockSelectSession = vi.fn(); let currentSelectedSession: unknown = null; vi.mock("./sessions", () => ({ sessions: { selectSession: (...args: unknown[]) => mockSelectSession(...args), }, selectedSession: { subscribe: vi.fn((callback: (value: unknown) => void) => { callback(currentSelectedSession); return () => {}; }), }, })); // Capture the playerStatusEvent listener so tests can drive backend // `playback_mode_changed` events through the reconciler. The commands still flow // to the real bindings (which call the mocked `invoke`), so the existing // refresh/transfer tests keep exercising the true command path. let capturedStatusListener: ((event: { payload: any }) => void) | null = null; vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn((_name: string, cb: (event: { payload: any }) => void) => { capturedStatusListener = cb; return Promise.resolve(() => {}); }), })); // Mock auth store const mockGetHandle = vi.fn(() => "repo-handle-1"); vi.mock("./auth", () => ({ auth: { getRepository: vi.fn(() => ({ getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }), getImageUrl: vi.fn().mockReturnValue("http://test.com/image"), getHandle: () => mockGetHandle(), })), }, })); describe("playbackMode store", () => { beforeEach(() => { vi.clearAllMocks(); currentSelectedSession = null; capturedStatusListener = null; }); afterEach(() => { vi.restoreAllMocks(); }); describe("initial state", () => { it("should have idle mode initially", async () => { const { playbackMode } = await import("./playbackMode"); const state = get(playbackMode); expect(state.mode).toBe("idle"); expect(state.remoteSessionId).toBeNull(); expect(state.isTransferring).toBe(false); expect(state.transferError).toBeNull(); }); }); describe("setMode", () => { it("should update mode locally", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("local"); let state = get(playbackMode); expect(state.mode).toBe("local"); playbackMode.setMode("remote", "session-123"); state = get(playbackMode); expect(state.mode).toBe("remote"); expect(state.remoteSessionId).toBe("session-123"); }); }); describe("disconnect", () => { it("should notify Rust backend and update local state when disconnecting", async () => { const { playbackMode } = await import("./playbackMode"); // Set up remote mode first playbackMode.setMode("remote", "session-123"); let state = get(playbackMode); expect(state.mode).toBe("remote"); expect(state.remoteSessionId).toBe("session-123"); // Mock successful Rust call mockInvoke.mockResolvedValueOnce(undefined); // Call disconnect await playbackMode.disconnect(); // Verify Rust backend was notified with correct mode (mode is now an object with type field) expect(mockInvoke).toHaveBeenCalledWith("playback_mode_set", { mode: { type: "idle" }, }); // Verify sessions.selectSession was called with null expect(mockSelectSession).toHaveBeenCalledWith(null); // Verify local state updated state = get(playbackMode); expect(state.mode).toBe("idle"); expect(state.remoteSessionId).toBeNull(); expect(state.transferError).toBeNull(); }); it("should do nothing if not in remote mode", async () => { const { playbackMode } = await import("./playbackMode"); // Ensure we're in idle mode playbackMode.setMode("idle"); const state = get(playbackMode); expect(state.mode).toBe("idle"); // Call disconnect await playbackMode.disconnect(); // Verify Rust backend was NOT called expect(mockInvoke).not.toHaveBeenCalled(); expect(mockSelectSession).not.toHaveBeenCalled(); }); it("should handle errors gracefully", async () => { const { playbackMode } = await import("./playbackMode"); // Set up remote mode first playbackMode.setMode("remote", "session-123"); // Mock failed Rust call const error = new Error("Failed to set mode"); mockInvoke.mockRejectedValueOnce(error); // Call disconnect and expect it to throw await expect(playbackMode.disconnect()).rejects.toThrow("Failed to set mode"); // Verify error was stored in state const state = get(playbackMode); expect(state.transferError).toBe("Failed to set mode"); }); it("should clear previous transfer errors on successful disconnect", async () => { const { playbackMode } = await import("./playbackMode"); // Set up remote mode with an existing error playbackMode.setMode("remote", "session-123"); // Manually set an error state (simulating a previous failed operation) // We'll use clearError then verify it's cleared on disconnect mockInvoke.mockResolvedValueOnce(undefined); await playbackMode.disconnect(); const state = get(playbackMode); expect(state.transferError).toBeNull(); }); }); describe("transferToRemote", () => { it("should call Rust backend with session ID", async () => { const { playbackMode } = await import("./playbackMode"); mockInvoke.mockResolvedValueOnce(undefined); await playbackMode.transferToRemote("session-456"); expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_remote", { sessionId: "session-456", position: null, }); }); it("should update local state on success", async () => { const { playbackMode } = await import("./playbackMode"); mockInvoke.mockResolvedValueOnce(undefined); await playbackMode.transferToRemote("session-456"); const state = get(playbackMode); expect(state.mode).toBe("remote"); expect(state.remoteSessionId).toBe("session-456"); expect(state.isTransferring).toBe(false); }); it("should set isTransferring during transfer", async () => { const { playbackMode } = await import("./playbackMode"); // Create a promise that we can control let resolveTransfer: () => void; const transferPromise = new Promise((resolve) => { resolveTransfer = resolve; }); mockInvoke.mockReturnValueOnce(transferPromise); // Start the transfer (don't await) const transferPromiseResult = playbackMode.transferToRemote("session-789"); // Check that isTransferring is true during the transfer let state = get(playbackMode); expect(state.isTransferring).toBe(true); // Resolve the transfer resolveTransfer!(); await transferPromiseResult; // Check that isTransferring is false after state = get(playbackMode); expect(state.isTransferring).toBe(false); }); }); describe("transferToLocal", () => { // 50,000,000 ticks = 5 seconds (10M ticks per second). const REMOTE_POSITION_TICKS = 50_000_000; const REMOTE_POSITION_SECONDS = 5; function setRemoteSessionPlaying() { currentSelectedSession = { id: "session-123", nowPlayingItem: { id: "item-abc", name: "Test Track", runTimeTicks: 1_800_000_000 }, playState: { positionTicks: REMOTE_POSITION_TICKS }, }; } it("resumes local playback at the remote position, not from 0", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("remote", "session-123"); setRemoteSessionPlaying(); mockInvoke.mockResolvedValue(undefined); await playbackMode.transferToLocal(); // Bug 2 guard: the resume position must be passed to play_tracks so the // backend seeks at load time. Restarting from 0 means startPosition is missing. const playTracksCall = mockInvoke.mock.calls.find((c) => c[0] === "player_play_tracks"); expect(playTracksCall).toBeDefined(); expect(playTracksCall![1]).toMatchObject({ repositoryHandle: "repo-handle-1", request: { trackIds: ["item-abc"], startIndex: 0, startPosition: REMOTE_POSITION_SECONDS, }, }); }); it("does not issue a separate player_seek (no start-at-0-then-jump race)", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("remote", "session-123"); setRemoteSessionPlaying(); mockInvoke.mockResolvedValue(undefined); await playbackMode.transferToLocal(); const seekCall = mockInvoke.mock.calls.find((c) => c[0] === "player_seek"); expect(seekCall).toBeUndefined(); }); it("tells Rust to stop the remote session with the current item and position", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("remote", "session-123"); setRemoteSessionPlaying(); mockInvoke.mockResolvedValue(undefined); await playbackMode.transferToLocal(); expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_local", { currentItemId: "item-abc", positionTicks: REMOTE_POSITION_TICKS, }); const state = get(playbackMode); expect(state.mode).toBe("local"); expect(state.remoteSessionId).toBeNull(); expect(state.isTransferring).toBe(false); }); it("switches to local without playing media when remote has nothing playing", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("remote", "session-123"); currentSelectedSession = { id: "session-123", nowPlayingItem: null, playState: null }; await playbackMode.transferToLocal(); expect(mockInvoke).not.toHaveBeenCalledWith("player_play_tracks", expect.anything()); const state = get(playbackMode); expect(state.mode).toBe("local"); expect(state.remoteSessionId).toBeNull(); }); }); describe("refresh (reconcile to Rust authoritative mode)", () => { it("adopts the Rust mode and aligns the selected session", async () => { const { playbackMode } = await import("./playbackMode"); // Start disagreeing with Rust: store thinks local, Rust says remote. playbackMode.setMode("local"); mockInvoke.mockResolvedValueOnce({ type: "remote", session_id: "sess-xyz" }); await playbackMode.refresh(); const state = get(playbackMode); expect(state.mode).toBe("remote"); expect(state.remoteSessionId).toBe("sess-xyz"); // The merged UI stores follow selectedSession, so it must be aligned too. expect(mockSelectSession).toHaveBeenCalledWith("sess-xyz"); }); it("clears the selected session when Rust reports non-remote", async () => { const { playbackMode } = await import("./playbackMode"); playbackMode.setMode("remote", "sess-old"); mockInvoke.mockResolvedValueOnce({ type: "idle" }); await playbackMode.refresh(); const state = get(playbackMode); expect(state.mode).toBe("idle"); expect(state.remoteSessionId).toBeNull(); expect(mockSelectSession).toHaveBeenCalledWith(null); }); }); describe("backend playback_mode_changed reconciler", () => { // Regression: commit 2a1f168 made Rust re-broadcast PlaybackModeChanged on // every set_mode. Local playback drives set_mode("local") from both the // frontend and Rust, so the same mode arrives repeatedly. The reconciler // used to run selectSession(null) on each one, deselecting the remote // session mid-cast and tripping the disconnect watchdog — which broke the // lockscreen card, remote volume, and (via the mode flap) local audio. async function initListener() { const { playbackMode } = await import("./playbackMode"); playbackMode.initializeSessionMonitoring(); expect(capturedStatusListener).not.toBeNull(); return playbackMode; } it("ignores a no-op remote re-broadcast (no session churn)", async () => { currentSelectedSession = { id: "sess-1" }; const playbackMode = await initListener(); playbackMode.setMode("remote", "sess-1"); mockSelectSession.mockClear(); // Rust re-broadcasts the SAME remote mode (e.g. a position tick path). capturedStatusListener!({ payload: { type: "playback_mode_changed", mode: "remote", session_id: "sess-1" }, }); const state = get(playbackMode); expect(state.mode).toBe("remote"); expect(state.remoteSessionId).toBe("sess-1"); // Must NOT re-select (which would churn the watchdog) on a no-op. expect(mockSelectSession).not.toHaveBeenCalled(); }); it("adopts a genuine remote→local change and clears the session", async () => { currentSelectedSession = { id: "sess-1" }; const playbackMode = await initListener(); playbackMode.setMode("remote", "sess-1"); mockSelectSession.mockClear(); capturedStatusListener!({ payload: { type: "playback_mode_changed", mode: "local", session_id: null }, }); const state = get(playbackMode); expect(state.mode).toBe("local"); expect(state.remoteSessionId).toBeNull(); expect(mockSelectSession).toHaveBeenCalledWith(null); }); }); describe("transfer reconciles to Rust on completion", () => { it("refreshes from Rust after a successful transferToRemote", async () => { const { playbackMode } = await import("./playbackMode"); // First call: the transfer command; second call: the finally refresh. mockInvoke.mockResolvedValueOnce(undefined); mockInvoke.mockResolvedValueOnce({ type: "remote", session_id: "session-456" }); await playbackMode.transferToRemote("session-456"); expect(mockInvoke).toHaveBeenCalledWith("playback_mode_get_current"); }); it("still reconciles to Rust when transferToRemote throws mid-transfer", async () => { const { playbackMode } = await import("./playbackMode"); // Transfer command fails, leaving the optimistic state possibly wrong. mockInvoke.mockRejectedValueOnce(new Error("boom")); // The finally refresh reads the true mode (Rust never left local). mockInvoke.mockResolvedValueOnce({ type: "local" }); await expect(playbackMode.transferToRemote("session-456")).rejects.toThrow("boom"); // The reconciling read must have happened despite the throw. expect(mockInvoke).toHaveBeenCalledWith("playback_mode_get_current"); const state = get(playbackMode); expect(state.mode).toBe("local"); }); }); describe("clearError", () => { it("should clear transfer error", async () => { const { playbackMode } = await import("./playbackMode"); // Set up remote mode and simulate a failed transfer to set an error playbackMode.setMode("remote", "session-123"); mockInvoke.mockRejectedValueOnce(new Error("Test error")); try { await playbackMode.disconnect(); } catch { // Expected to throw } let state = get(playbackMode); expect(state.transferError).toBe("Test error"); // Clear the error playbackMode.clearError(); state = get(playbackMode); expect(state.transferError).toBeNull(); }); }); describe("derived stores", () => { it("isRemoteMode should be true when mode is remote", async () => { const { playbackMode, isRemoteMode } = await import("./playbackMode"); playbackMode.setMode("remote", "session-123"); const isRemote = get(isRemoteMode); expect(isRemote).toBe(true); }); it("isLocalMode should be true when mode is local", async () => { const { playbackMode, isLocalMode } = await import("./playbackMode"); playbackMode.setMode("local"); const isLocal = get(isLocalMode); expect(isLocal).toBe(true); }); it("isIdleMode should be true when mode is idle", async () => { const { playbackMode, isIdleMode } = await import("./playbackMode"); playbackMode.setMode("idle"); const isIdle = get(isIdleMode); expect(isIdle).toBe(true); }); }); });