/** * Unit tests for Html5PlayerAdapter. * * The Option-1 primitive design makes the adapter pure, decision-free mechanics * — it takes a mock element + bridge + host, so we can assert each * primitive drives the element correctly without any real DOM or backend. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter"; import type { AdapterHost } from "./types"; /** A minimal fake element that records mutations and fires events. */ function makeFakeVideo() { const listeners: Record void>> = {}; const el: any = { paused: true, currentTime: 0, volume: 1, muted: false, src: "blob:existing", play: vi.fn(async () => { el.paused = false; }), pause: vi.fn(() => { el.paused = true; }), load: vi.fn(), removeAttribute: vi.fn((attr: string) => { if (attr === "src") el.src = ""; }), addEventListener: (event: string, cb: () => void) => { (listeners[event] ??= []).push(cb); }, removeEventListener: (event: string, cb: () => void) => { listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb); }, // Test helper: fire an event so waitForEvent resolves immediately. _fire: (event: string) => { (listeners[event] ?? []).slice().forEach((f) => f()); }, querySelectorAll: () => [] as any, textTracks: [] as any, }; return el; } type FakeVideo = ReturnType; function makeBridge(overrides: Partial = {}): Html5ElementBridge { let offset = 0; return { getElement: () => null, getSeekOffset: () => offset, setSeekOffset: vi.fn((o: number) => { offset = o; }), setStreamUrl: vi.fn(), destroyHls: vi.fn(), getMediaSourceId: () => "msid-1", ...overrides, }; } 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("Html5PlayerAdapter", () => { let host: AdapterHost; let bridge: Html5ElementBridge; let adapter: Html5PlayerAdapter; let video: ReturnType; beforeEach(() => { host = makeHost(); bridge = makeBridge(); adapter = new Html5PlayerAdapter(host, bridge); video = makeFakeVideo(); adapter.attach(video); }); it("is an html5-kind adapter", () => { expect(adapter.kind).toBe("html5"); }); it("play() calls element.play()", async () => { await adapter.play(); expect(video.play).toHaveBeenCalledTimes(1); }); it("pause() calls element.pause()", async () => { video.paused = false; await adapter.pause(); expect(video.pause).toHaveBeenCalledTimes(1); }); it("toggle() plays when paused and reports the resulting state", async () => { video.paused = true; const playing = await adapter.toggle(); expect(video.play).toHaveBeenCalled(); expect(playing).toBe(true); }); it("toggle() pauses when playing", async () => { video.paused = false; const playing = await adapter.toggle(); expect(video.pause).toHaveBeenCalled(); expect(playing).toBe(false); }); it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => { const p = adapter.seekElement(42, 0); expect(video.currentTime).toBe(42); expect(bridge.setSeekOffset).toHaveBeenCalledWith(0); video._fire("seeked"); // resolve the wait await p; }); it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => { video.paused = false; // was playing → should resume const p = adapter.reloadSource("http://new/master.m3u8", 120); // Teardown happened synchronously before the awaited canplay wait. expect(video.pause).toHaveBeenCalled(); expect(bridge.destroyHls).toHaveBeenCalledTimes(1); expect(video.removeAttribute).toHaveBeenCalledWith("src"); expect(video.load).toHaveBeenCalled(); // Allow the internal 100ms settle delay, then fire canplay to resume. await new Promise((r) => setTimeout(r, 110)); expect(bridge.setSeekOffset).toHaveBeenCalledWith(120); expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8"); video._fire("canplay"); await p; expect(video.play).toHaveBeenCalled(); // resumed because it was playing }); it("reloadSource() does not resume when it was paused", async () => { video.paused = true; const p = adapter.reloadSource("http://new/master.m3u8", 30); await new Promise((r) => setTimeout(r, 110)); video._fire("canplay"); await p; expect(video.play).not.toHaveBeenCalled(); }); it("setVolume() clamps to 0..1", () => { adapter.setVolume(1.5); expect(video.volume).toBe(1); adapter.setVolume(-0.5); expect(video.volume).toBe(0); adapter.setVolume(0.4); expect(video.volume).toBeCloseTo(0.4); }); it("setMuted() sets the element muted flag", () => { adapter.setMuted(true); expect(video.muted).toBe(true); }); it("getPosition() returns element time plus the transcode offset", () => { video.currentTime = 10; (bridge.getSeekOffset as any) = () => 100; // Rebuild adapter with the offset-returning bridge. const a = new Html5PlayerAdapter(host, bridge); a.attach(video); expect(a.getPosition()).toBe(110); }); it("dispose() tears down hls and clears the element", async () => { await adapter.dispose(); expect(bridge.destroyHls).toHaveBeenCalled(); expect(video.pause).toHaveBeenCalled(); // After dispose, primitives are no-ops (element detached). await adapter.play(); // play was called once during dispose teardown? no — play only on reload/resume. expect(video.play).not.toHaveBeenCalled(); }); it("primitives are safe no-ops before an element is attached", async () => { const bare = new Html5PlayerAdapter(host, bridge); await expect(bare.play()).resolves.toBeUndefined(); await expect(bare.pause()).resolves.toBeUndefined(); await expect(bare.seekElement(5, 0)).resolves.toBeUndefined(); expect(await bare.toggle()).toBe(false); }); });