/** * Unit tests for NativePlayerAdapter — thin delegate to backend commands. * Pins the primitive→command mapping so the ExoPlayer path stays correct. */ import { describe, it, expect, vi, beforeEach } from "vitest"; const playerPlay = vi.fn((..._a: any[]): any => ({})); const playerPause = vi.fn((..._a: any[]): any => ({})); const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" })); const playerSetVolume = vi.fn((..._a: any[]): any => ({})); const playerToggleMute = vi.fn((..._a: any[]): any => ({})); const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({})); const playerSeek = vi.fn((..._a: any[]): any => ({})); vi.mock("$lib/api/bindings", () => ({ commands: { playerPlay: (...a: any[]) => playerPlay(...a), playerPause: (...a: any[]) => playerPause(...a), playerToggle: (...a: any[]) => playerToggle(...a), playerSetVolume: (...a: any[]) => playerSetVolume(...a), playerToggleMute: (...a: any[]) => playerToggleMute(...a), playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a), playerSeek: (...a: any[]) => playerSeek(...a), }, })); import { NativePlayerAdapter } from "./nativeAdapter"; import type { AdapterHost } from "./types"; /** * A `StreamSelection` for tests that only care about the URL. Transcoded HLS is * what these paths exercised before the contract carried a transport. */ function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") { return { url, transport: { type: transport }, playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" }, rendition: null, available: [], mediaSourceId: null, playSessionId: null, needsTranscoding: transport === "hls", } as import("$lib/api/bindings").StreamSelection; } function makeHost(): AdapterHost { return { onState: vi.fn(), onPosition: vi.fn(), onMediaLoaded: vi.fn(), onEnded: vi.fn(), onError: vi.fn(), onStreamUrlChanged: vi.fn(), onBuffering: vi.fn(), onReady: vi.fn(), }; } describe("NativePlayerAdapter", () => { let adapter: NativePlayerAdapter; beforeEach(() => { vi.clearAllMocks(); adapter = new NativePlayerAdapter(makeHost()); }); it("is a native-kind adapter", () => { expect(adapter.kind).toBe("native"); }); it("delegates play/pause to backend commands", async () => { await adapter.play(); await adapter.pause(); expect(playerPlay).toHaveBeenCalledTimes(1); expect(playerPause).toHaveBeenCalledTimes(1); }); it("toggle() reflects the backend's resulting playing state", async () => { expect(await adapter.toggle()).toBe(true); expect(playerToggle).toHaveBeenCalledTimes(1); }); it("records position on seek/reload primitives (backend does the real work)", async () => { await adapter.seekElement(55, 0); expect(adapter.getPosition()).toBe(55); await adapter.reloadSource(testSelection("ignored"), 200); expect(adapter.getPosition()).toBe(200); }); it("load() seeds a resume position", async () => { await adapter.load("url", { mediaId: "m", mediaSourceId: null, needsTranscoding: false, initialPosition: 90, isLive: false, audioTrackIndex: null, knownDuration: 0, subtitleTracks: [], }); expect(adapter.getPosition()).toBe(90); }); // Regression: resume-at-position was broken on Android. player_play_item // carries no start position, and ExoPlayer always begins at 0, so recording // the number frontend-side left the backend playing from the beginning. The // adapter must actually *issue* the seek. it("load() issues the resume seek to the backend, not just records it", async () => { await adapter.load("url", { mediaId: "m", mediaSourceId: null, needsTranscoding: false, initialPosition: 90, isLive: false, audioTrackIndex: null, knownDuration: 0, subtitleTracks: [], }); expect(playerSeek).toHaveBeenCalledWith(90); }); it("load() does not seek when starting from the beginning", async () => { await adapter.load("url", { mediaId: "m", mediaSourceId: null, needsTranscoding: false, initialPosition: 0, isLive: false, audioTrackIndex: null, knownDuration: 0, subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); // A live stream has no meaningful resume point; seeking one is at best a // no-op and at worst knocks the HLS window off its live edge. it("load() never seeks a live stream", async () => { await adapter.load("url", { mediaId: "m", mediaSourceId: null, needsTranscoding: false, initialPosition: 90, isLive: true, audioTrackIndex: null, knownDuration: 0, subtitleTracks: [], }); expect(playerSeek).not.toHaveBeenCalled(); }); it("setVolume clamps and delegates; setMuted toggles mute", () => { adapter.setVolume(2); expect(playerSetVolume).toHaveBeenCalledWith(1); adapter.setMuted(true); expect(playerToggleMute).toHaveBeenCalledTimes(1); }); it("selectSubtitle maps null to disable and uses arrayIndex when given", async () => { await adapter.selectSubtitle(null); expect(playerSetSubtitleTrack).toHaveBeenCalledWith(null); await adapter.selectSubtitle(5, 2); expect(playerSetSubtitleTrack).toHaveBeenCalledWith(2); }); });