import { describe, it, expect } from "vitest"; import { ParentMediaManager, type ProxyEntry } from "./parent-media"; // A fake media element whose paused state is driven by play()/pause() stubs. function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement { const el = new Audio(); let paused = initiallyPaused; Object.defineProperty(el, "paused", { get: () => paused }); el.pause = () => { paused = true; }; el.play = () => { paused = false; return Promise.resolve(); }; el.src = "https://example.test/music.mp3"; return el; } function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) { const mgr = new ParentMediaManager({ dispatchEvent: () => {}, getMuted: () => false, getVolume: () => 1, getPlaybackRate: () => 1, getCurrentTime: () => 0, isPaused: () => overrides.isPaused ?? true, }); return mgr; } describe("ParentMediaManager audio-src proxy lifecycle", () => { it("replaces the audio-src proxy instead of stacking a second one", () => { const mgr = makeManager(); mgr.setupFromUrl("https://example.test/a.mp3"); expect(mgr.entries).toHaveLength(1); mgr.setupFromUrl("https://example.test/b.mp3"); // The old proxy must be gone, not accumulated alongside the new one. expect(mgr.entries).toHaveLength(1); expect(mgr.entries[0].el.src).toBe("https://example.test/b.mp3"); }); it("is a no-op when the same audio-src URL is set again", () => { const mgr = makeManager(); mgr.setupFromUrl("https://example.test/a.mp3"); const first = mgr.entries[0]; mgr.setupFromUrl("https://example.test/a.mp3"); expect(mgr.entries).toHaveLength(1); // Same element reference — not torn down and rebuilt. expect(mgr.entries[0]).toBe(first); }); it("clears the audio-src proxy on teardownUrlAudio", () => { const mgr = makeManager(); mgr.setupFromUrl("https://example.test/a.mp3"); const el = mgr.entries[0].el; mgr.teardownUrlAudio(); expect(mgr.entries).toHaveLength(0); // The proxy's source is reset so it stops preloading. expect(el.src).not.toBe("https://example.test/a.mp3"); }); it("teardownUrlAudio removes only the url proxy, leaving other entries", () => { const mgr = makeManager(); // Simulate an iframe-adopted entry already in the pool. const adopted: ProxyEntry = { el: new Audio(), start: 0, duration: Infinity, driftSamples: 0, }; adopted.el.src = "https://example.test/iframe-clip.mp4"; mgr.entries.push(adopted); mgr.setupFromUrl("https://example.test/a.mp3"); expect(mgr.entries).toHaveLength(2); mgr.teardownUrlAudio(); expect(mgr.entries).toHaveLength(1); expect(mgr.entries[0]).toBe(adopted); }); it("teardownUrlAudio is safe to call with no audio-src set", () => { const mgr = makeManager(); expect(() => mgr.teardownUrlAudio()).not.toThrow(); expect(mgr.entries).toHaveLength(0); }); it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => { const mgr = makeManager({ owner: "parent", isPaused: false }); const el = makeFakeAudio(false); // already playing within the clip mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 }); mgr.mirrorTime(3); // inside [0, 5) — stays playing expect(el.paused).toBe(false); mgr.mirrorTime(6); // past the trimmed end — must pause expect(el.paused).toBe(true); }); it("re-reads the source element's live data-duration so trims bound the proxy", () => { const mgr = makeManager({ owner: "parent", isPaused: false }); const source = new Audio(); source.setAttribute("data-start", "0"); source.setAttribute("data-duration", "30"); // jsdom reports isConnected=false unless attached; attach it. document.body.appendChild(source); const el = makeFakeAudio(false); mgr.entries.push({ el, start: 0, duration: 30, driftSamples: 0, source }); mgr.mirrorTime(20); // within 30 → playing expect(el.paused).toBe(false); // User trims the clip to 10s; the proxy must pick it up and pause at 20s. source.setAttribute("data-duration", "10"); mgr.mirrorTime(20); expect(el.paused).toBe(true); source.remove(); }); it("scrubAll plays in-window proxies at the playhead and pauses out-of-window ones", () => { const mgr = makeManager({ owner: "parent" }); const inWin = makeFakeAudio(true); // currently paused — scrub should start it const outWin = makeFakeAudio(false); // currently playing, but outside its window mgr.entries.push({ el: inWin, start: 0, duration: 5, driftSamples: 0 }); mgr.entries.push({ el: outWin, start: 10, duration: 5, driftSamples: 0 }); mgr.scrubAll(2); // playhead at 2s // in-window proxy: positioned at rel time and AUDIBLE (the point of scrub-audio) expect(inWin.currentTime).toBe(2); expect(inWin.paused).toBe(false); // out-of-window proxy: paused, not blipped expect(outWin.paused).toBe(true); }); it("does not duplicate or hijack a clip the composition already owns", () => { const mgr = makeManager(); // The composition already adopted a clip with this URL. const adopted: ProxyEntry = { el: new Audio(), start: 0, duration: Infinity, driftSamples: 0, }; adopted.el.src = "https://example.test/shared.mp3"; mgr.entries.push(adopted); // Pointing audio-src at the same URL must not create a second proxy... mgr.setupFromUrl("https://example.test/shared.mp3"); expect(mgr.entries).toHaveLength(1); expect(mgr.entries[0]).toBe(adopted); // ...and removing audio-src must not tear down the composition's own clip // (teardown targets the tracked proxy by reference, not by URL match). mgr.teardownUrlAudio(); expect(mgr.entries).toHaveLength(1); expect(mgr.entries[0]).toBe(adopted); }); });