/* RFC-1175: tests for background-video runtime — capability gating (saveData, reduced-motion), viewport play/pause, poster fallback. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. RFC-1175: initial background-video tests. RFC-1175: close Lighthouse gaps — directive-allowlist robots (Schemamap retired, ROBOTS-SURFACE-01), capability-gated video (preload=none, data-bg-video, saveData/reduced-motion), single fetchpriority=high per page (IMG-DELIVERY-05), inlined shared texture token, 24px QR touch targets; RFC-1177 security-headers draft */ import { test, expect, vi, beforeEach, afterEach } from "vitest"; interface MockVideo { attrs: Record; play: ReturnType; pause: ReturnType; setAttribute: (name: string, value: string) => void; removeAttribute: (name: string) => void; } interface MockDocument { querySelectorAll: ReturnType; addEventListener: ReturnType; documentElement: { setAttribute: ReturnType }; hidden: boolean; } function createMockVideo(): MockVideo { const video: MockVideo = { attrs: {}, play: vi.fn(() => Promise.resolve()), pause: vi.fn(), setAttribute: (name, value) => { video.attrs[name] = value; }, removeAttribute: (name) => { delete video.attrs[name]; }, }; return video; } let observerCallback: ((entries: Array<{ isIntersecting: boolean; target: MockVideo }>) => void) | null; let observerOptions: { threshold?: number } | undefined; let mockDocument: MockDocument; let listeners: Record void>; class MockIntersectionObserver { constructor( cb: (entries: Array<{ isIntersecting: boolean; target: MockVideo }>) => void, opts: { threshold?: number }, ) { observerCallback = cb; observerOptions = opts; } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); } beforeEach(() => { vi.clearAllMocks(); observerCallback = null; observerOptions = undefined; listeners = {}; mockDocument = { querySelectorAll: vi.fn(() => []), addEventListener: vi.fn((event: string, cb: () => void) => { listeners[event] = cb; }), // RFC-1175: SMOKE-INIT-01 marker writes data-bg-video-initialized here. documentElement: { setAttribute: vi.fn() }, hidden: false, }; vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); vi.stubGlobal("document", mockDocument); vi.stubGlobal("window", globalThis); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); async function init(videos: MockVideo[], opts: { saveData?: boolean } = {}) { mockDocument.querySelectorAll.mockReturnValue(videos); vi.stubGlobal("navigator", { connection: { saveData: opts.saveData ?? false } }); const { initBackgroundVideos } = await import("../background-video.ts"); initBackgroundVideos({ prefersReducedMotion: false }); } test("initBackgroundVideos: no-ops when no [data-bg-video] elements exist", async () => { await init([]); expect(observerCallback).toBeNull(); }); test("initBackgroundVideos: never plays under prefers-reduced-motion", async () => { const video = createMockVideo(); mockDocument.querySelectorAll.mockReturnValue([video]); vi.stubGlobal("navigator", { connection: { saveData: false } }); const { initBackgroundVideos } = await import("../background-video.ts"); initBackgroundVideos({ prefersReducedMotion: true }); expect(video.play).not.toHaveBeenCalled(); expect(observerCallback).toBeNull(); }); test("initBackgroundVideos: never fetches bytes under saveData (poster stays)", async () => { const video = createMockVideo(); await init([video], { saveData: true }); expect(video.play).not.toHaveBeenCalled(); expect(observerCallback).toBeNull(); expect(video.attrs.autoplay).toBeUndefined(); }); test("initBackgroundVideos: plays in view, pauses out of view", async () => { const video = createMockVideo(); await init([video]); expect(observerCallback, "IntersectionObserver must be created").not.toBeNull(); expect(observerOptions?.threshold).toBe(0.1); observerCallback!([{ isIntersecting: true, target: video }]); expect(video.attrs.autoplay).toBe(""); expect(video.play).toHaveBeenCalled(); observerCallback!([{ isIntersecting: false, target: video }]); expect(video.pause).toHaveBeenCalled(); expect(video.attrs.autoplay).toBeUndefined(); }); test("initBackgroundVideos: pauses every video when the tab hides", async () => { const a = createMockVideo(); const b = createMockVideo(); await init([a, b]); observerCallback!([ { isIntersecting: true, target: a }, { isIntersecting: true, target: b }, ]); mockDocument.hidden = true; listeners["visibilitychange"]!(); expect(a.pause).toHaveBeenCalled(); expect(b.pause).toHaveBeenCalled(); }); test("initBackgroundVideos: plays immediately when IntersectionObserver is unavailable", async () => { // window without IntersectionObserver — `in` check must be false. vi.stubGlobal("window", {}); const video = createMockVideo(); await init([video]); expect(video.attrs.autoplay).toBe(""); expect(video.play).toHaveBeenCalled(); }); test("initBackgroundVideos: a rejected play() promise stays silent (poster fallback)", async () => { const video = createMockVideo(); video.play.mockImplementation(() => Promise.reject(new Error("autoplay blocked"))); await init([video]); observerCallback!([{ isIntersecting: true, target: video }]); // The .catch() swallows the policy rejection — no unhandled rejection escapes. await expect(Promise.resolve()).resolves.toBeUndefined(); });