import { criarSom } from "./som"; class GanhoFalso { gain = { value: 1 }; connect = jest.fn(); disconnect = jest.fn(); } class FonteFalsa { buffer: unknown = null; playbackRate = { value: 1 }; onended: (() => void) | null = null; connect = jest.fn(); start = jest.fn(); stop = jest.fn(); } class ContextoFalso { static ultimo: ContextoFalso | null = null; destination = {}; ganhos: GanhoFalso[] = []; fontes: FonteFalsa[] = []; close = jest.fn(() => Promise.resolve()); constructor() { ContextoFalso.ultimo = this; } state = "running"; resume = jest.fn(() => Promise.resolve()); decodeAudioData() { return Promise.resolve({}); } createGain() { const g = new GanhoFalso(); this.ganhos.push(g); return g; } createBufferSource() { const f = new FonteFalsa(); this.fontes.push(f); return f; } } const g = globalThis as unknown as { AudioContext?: unknown; fetch?: unknown }; function respostaOk() { return Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), }); } beforeEach(() => { ContextoFalso.ultimo = null; g.AudioContext = ContextoFalso; g.fetch = jest.fn(respostaOk); }); afterEach(() => { delete g.AudioContext; delete g.fetch; }); it("tocar antes de liberar não quebra nem cria contexto", () => { const som = criarSom(); expect(() => som.tocar("pistola")).not.toThrow(); expect(ContextoFalso.ultimo).toBeNull(); expect(g.fetch).not.toHaveBeenCalled(); }); it("liberar cria o contexto e baixa os dez sons", async () => { const som = criarSom(); await som.liberar(); expect(ContextoFalso.ultimo).not.toBeNull(); expect(g.fetch).toHaveBeenCalledTimes(10); expect(g.fetch).toHaveBeenCalledWith("/jogo/sons/gemido-1.mp3"); som.tocar("acerto", { volume: 0.4 }); const ctx = ContextoFalso.ultimo as ContextoFalso; expect(ctx.fontes).toHaveLength(1); expect(ctx.fontes[0].start).toHaveBeenCalled(); expect(ctx.ganhos.at(-1)?.gain.value).toBe(0.4); }); it("liberar de novo retoma o contexto parado, sem lançar se a retomada falhar", async () => { const som = criarSom(); await som.liberar(); const ctx = ContextoFalso.ultimo as ContextoFalso; ctx.resume.mockClear(); await som.liberar(); expect(ctx.resume).not.toHaveBeenCalled(); for (const parado of ["suspended", "interrupted"]) { ctx.state = parado; ctx.resume.mockImplementationOnce(() => Promise.reject(new Error("não"))); await expect(som.liberar()).resolves.toBeUndefined(); } expect(ctx.resume).toHaveBeenCalledTimes(2); expect(ContextoFalso.ultimo).toBe(ctx); }); it("no máximo 8 vozes: a mais antiga é cortada", async () => { const som = criarSom(); await som.liberar(); for (let i = 0; i < 10; i++) som.tocar("automatica"); const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes; expect(fontes).toHaveLength(10); expect(fontes.filter((f) => f.stop.mock.calls.length > 0)).toEqual([ fontes[0], fontes[1], ]); }); it("voz que terminou libera a vaga", async () => { const som = criarSom(); await som.liberar(); for (let i = 0; i < 8; i++) som.tocar("pistola"); const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes; fontes[3].onended?.(); som.tocar("pistola"); expect(fontes.some((f) => f.stop.mock.calls.length > 0)).toBe(false); }); it("variar muda a velocidade em até 6%", async () => { const som = criarSom(); await som.liberar(); for (let i = 0; i < 20; i++) som.tocar("pistola", { variar: true }); for (const f of (ContextoFalso.ultimo as ContextoFalso).fontes) { expect(f.playbackRate.value).toBeGreaterThanOrEqual(0.94); expect(f.playbackRate.value).toBeLessThanOrEqual(1.06); } }); it("mudo zera o ganho geral, antes ou depois de liberar", async () => { const som = criarSom(); som.definirMudo(true); await som.liberar(); const geral = (ContextoFalso.ultimo as ContextoFalso).ganhos[0]; expect(geral.gain.value).toBe(0); som.definirMudo(false); expect(geral.gain.value).toBe(1); som.definirMudo(true); expect(geral.gain.value).toBe(0); }); it("falha de download não lança erro e o som que faltou não toca", async () => { const erro = jest.spyOn(console, "error").mockImplementation(() => {}); g.fetch = jest.fn((url: string) => { if (url.includes("pistola")) return Promise.reject(new Error("rede")); if (url.includes("faca")) return Promise.resolve({ ok: false }); return respostaOk(); }); const som = criarSom(); await expect(som.liberar()).resolves.toBeUndefined(); som.tocar("pistola"); som.tocar("faca"); som.tocar("golpe"); expect((ContextoFalso.ultimo as ContextoFalso).fontes).toHaveLength(1); expect(erro).not.toHaveBeenCalled(); erro.mockRestore(); }); it("sem AudioContext tudo vira no-op", async () => { delete g.AudioContext; const som = criarSom(); await expect(som.liberar()).resolves.toBeUndefined(); expect(() => { som.tocar("drone"); som.definirMudo(true); som.descartar(); }).not.toThrow(); expect(g.fetch).not.toHaveBeenCalled(); }); it("descartar fecha o contexto e cala o resto", async () => { const som = criarSom(); await som.liberar(); som.tocar("caixa"); som.descartar(); const ctx = ContextoFalso.ultimo as ContextoFalso; expect(ctx.close).toHaveBeenCalled(); expect(ctx.fontes[0].stop).toHaveBeenCalled(); som.tocar("caixa"); expect(ctx.fontes).toHaveLength(1); });