🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s
477 lines
16 KiB
TypeScript
477 lines
16 KiB
TypeScript
// 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<void>((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);
|
|
});
|
|
});
|
|
});
|