/** * Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview. * * TRACES: UR-005 | DR-097 | UT-091 * * The frontend used to short-circuit transport controls whenever a video adapter * was registered: `toggle()` read `el.paused` off the DOM and flipped the element * directly, so the Rust `PlayerController` never saw the intent and could not * serialise competing ones. Because `el.paused` flips transiently while an HTML5 * element buffers or settles a seek, two intents arriving ~150ms apart could read * *different* values and perform *opposing* actions — one playing, one pausing — * which is the self-sustaining play/pause loop observed on Android. * * The rule these tests pin: a transport intent always reaches the backend. Rust * decides play-vs-pause from controller state and drives the webview element back * through a ControlCommand event (the same "backend decides, adapter executes" * split `player_seek_video` already uses). */ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockCommands = { playerPlay: vi.fn(async () => ({})), playerPause: vi.fn(async () => ({})), playerToggle: vi.fn(async () => ({})), playerStop: vi.fn(async () => ({})), }; vi.mock("$lib/api/bindings", () => ({ commands: mockCommands, // Stores pulled in transitively subscribe to typed events at module load. events: { playerStatusEvent: { listen: vi.fn(async () => () => {}) }, downloadEvent: { listen: vi.fn(async () => () => {}) }, searchEvent: { listen: vi.fn(async () => () => {}) }, }, })); vi.mock("$lib/stores/auth", () => ({ auth: { subscribe: (fn: (v: unknown) => void) => { fn({ isAuthenticated: true }); return () => {}; }, getRepository: () => ({ getHandle: () => "handle-1" }), }, })); /** A video adapter that records whether the facade reached into it directly. */ function makeAdapter() { return { kind: "html5" as const, play: vi.fn(async () => {}), pause: vi.fn(async () => {}), toggle: vi.fn(async () => true), seekElement: vi.fn(async () => {}), reloadSource: vi.fn(async () => {}), attach: vi.fn(), dispose: vi.fn(async () => {}), setVolume: vi.fn(), setMuted: vi.fn(), selectSubtitle: vi.fn(async () => {}), getPosition: vi.fn(() => 0), load: vi.fn(async () => {}), }; } describe("transport authority lives in Rust", () => { let playerController: any; let adapter: ReturnType; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); ({ playerController } = await import("./index")); adapter = makeAdapter(); playerController.setActiveAdapter(adapter); }); it("routes toggle to the backend even when a video adapter is active", async () => { await playerController.toggle(); expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1); // The webview must NOT decide play-vs-pause from the DOM. expect(adapter.toggle).not.toHaveBeenCalled(); }); it("routes play to the backend even when a video adapter is active", async () => { await playerController.play(); expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1); expect(adapter.play).not.toHaveBeenCalled(); }); it("routes pause to the backend even when a video adapter is active", async () => { await playerController.pause(); expect(mockCommands.playerPause).toHaveBeenCalledTimes(1); expect(adapter.pause).not.toHaveBeenCalled(); }); it("still routes transport to the backend with no adapter (audio path unchanged)", async () => { playerController.clearActiveAdapter(); await playerController.toggle(); await playerController.play(); await playerController.pause(); expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1); expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1); expect(mockCommands.playerPause).toHaveBeenCalledTimes(1); }); });