/** * Regression tests for the video player's track / quality / subtitle menus, * rendered against the REAL component. * * TRACES: UR-020, UR-021, UR-066, UR-074 | DR-256 | UT-227, UT-228 * * Two defects shipped together, and neither is visible from a pure helper: * * 1. Each menu owned its own `show…` boolean and no toggle cleared the others, * so opening the subtitle menu on top of the audio menu left two panels * overlapping in the same corner — the newer one covering rows of the * older one, both still live. * * 2. Each panel was `absolute right-0` against *its own icon button*, which * sits mid-row. A 200–220 px panel hung off the left edge of a portrait * phone, so half the tracks could not be read or tapped. * * Both are properties of the composition, so these tests drive the real * markup: click the toggles, then assert what a viewer would see. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, fireEvent } from "@testing-library/svelte"; import { tick } from "svelte"; import { invoke } from "@tauri-apps/api/core"; import VideoPlayer from "./VideoPlayer.svelte"; function testSelection() { return { url: "http://x/master.m3u8", transport: { type: "hls" }, playbackKind: { type: "transcode" }, rendition: null, available: [ { quality: "original", label: "Original", detail: "Source", exceedsSource: false, sourceBitrate: 8_000_000, }, { quality: "high", label: "8 Mbps", detail: "1080p", exceedsSource: false, sourceBitrate: 8_000_000, }, ], mediaSourceId: null, playSessionId: null, needsTranscoding: true, } as unknown as import("$lib/api/bindings").StreamSelection; } vi.mock("$app/navigation", () => ({ goto: vi.fn() })); vi.mock("$lib/player", () => ({ playerController: { toggle: vi.fn(() => Promise.resolve()), seekVideo: vi.fn(() => Promise.resolve()), seek: vi.fn(() => Promise.resolve()), setActiveAdapter: vi.fn(), clearActiveAdapter: vi.fn(), getActiveAdapter: vi.fn(() => null), switchAudioTrack: vi.fn(() => Promise.resolve()), setStreamQuality: vi.fn(() => Promise.resolve(null)), }, })); vi.mock("$lib/player/adapters/rustReportHost", () => ({ createRustReportHost: () => ({ onState: vi.fn(), onPosition: vi.fn(), onMediaLoaded: vi.fn(), onEnded: vi.fn(), onError: vi.fn(), onStreamUrlChanged: vi.fn(), onBuffering: vi.fn(), onReady: vi.fn(), }), })); vi.mock("$lib/player/html5Adapter", () => ({ reportState: vi.fn(), reportPosition: vi.fn(), reportMediaLoaded: vi.fn(), resetReporting: vi.fn(), })); vi.mock("$lib/utils/pictureInPicture", () => ({ isPipSupported: () => false, enterPip: vi.fn(), setAutoEnterEnabled: vi.fn(), setHtml5VideoState: vi.fn(), })); vi.mock("$lib/stores/auth", () => ({ auth: { getUserId: () => "user-1", getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }), subscribe: (fn: (v: unknown) => void) => { fn({ isAuthenticated: true }); return () => {}; }, }, })); /** Two audio tracks and one subtitle track — enough for all three menus. */ const MEDIA = { id: "item-1", name: "Test Episode", type: "Episode", runTimeTicks: 6_000_000_000, durationMs: 600_000, mediaStreams: [ { index: 1, kind: "audio", displayTitle: "English AAC", language: "eng", isDefault: true }, { index: 2, kind: "audio", displayTitle: "Commentary", language: "eng" }, { index: 3, kind: "subtitle", displayTitle: "English SRT", language: "eng", codec: "srt", deliverableAsSidecar: true, }, ], } as any; function renderPlayer() { return render(VideoPlayer, { props: { media: MEDIA, selection: testSelection(), onClose: vi.fn() }, }); } /** The menu panels currently on screen, found by their headings. */ function openPanels(container: HTMLElement): string[] { return ["Audio Track", "Quality", "Subtitles"].filter((heading) => [...container.querySelectorAll("div")].some( (el) => el.children.length === 0 && el.textContent?.trim() === heading, ), ); } function clickToggle(container: HTMLElement, label: string) { const button = container.querySelector(`button[aria-label="${label}"]`); expect(button, `expected a "${label}" button in the controls`).toBeTruthy(); return fireEvent.click(button!); } describe("VideoPlayer track menus", () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(invoke).mockImplementation(async (cmd: string) => { switch (cmd) { case "player_get_streaming_qualities": return []; case "player_get_video_settings": return { streamingQuality: "original" }; default: return undefined; } }); }); // UT-227 it("opening one menu closes any other — never two panels stacked in the corner", async () => { const { container } = renderPlayer(); await tick(); await clickToggle(container, "Select audio track"); await tick(); expect(openPanels(container)).toEqual(["Audio Track"]); await clickToggle(container, "Select subtitles"); await tick(); expect(openPanels(container)).toEqual(["Subtitles"]); await clickToggle(container, "Select streaming quality"); await tick(); expect(openPanels(container)).toEqual(["Quality"]); // A second click on the open menu's own toggle closes it. await clickToggle(container, "Select streaming quality"); await tick(); expect(openPanels(container)).toEqual([]); }); // UT-227 it("the volume slider is part of the same group — it closes an open track menu", async () => { const { container } = renderPlayer(); await tick(); await clickToggle(container, "Select subtitles"); await tick(); expect(openPanels(container)).toEqual(["Subtitles"]); // The volume popup (desktop only) is a menu of this bar too. const volume = container.querySelector('button[title="Volume"]'); expect(volume).toBeTruthy(); await fireEvent.click(volume!); await tick(); expect(container.querySelector("[aria-label='Volume controls']")).toBeTruthy(); expect(openPanels(container)).toEqual([]); // …and a track menu closes the volume popup again. await clickToggle(container, "Select subtitles"); await tick(); expect(container.querySelector("[aria-label='Volume controls']")).toBeNull(); expect(openPanels(container)).toEqual(["Subtitles"]); }); // UT-228 it("the open panel is anchored to the control bar and clamped to the viewport", async () => { const { container } = renderPlayer(); await tick(); for (const label of ["Select audio track", "Select subtitles", "Select streaming quality"]) { await clickToggle(container, label); await tick(); const panel = container.querySelector("[data-testid='player-menu']"); expect(panel, `${label} should open the shared menu panel`).toBeTruthy(); // Anchored to the control row, not to the icon button: a panel anchored // to a mid-row button runs off the left edge in portrait. const toggle = container.querySelector(`button[aria-label="${label}"]`); expect(panel!.contains(toggle!)).toBe(false); expect(toggle!.parentElement!.contains(panel!)).toBe(false); // …and never wider than the screen it opens on. expect(panel!.className).toMatch(/max-w-\[|w-\[min\(/); await clickToggle(container, label); await tick(); } }); });