import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AudioManager, type PlayOptions } from '../AudioManager';
/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */
function makeParam(value = 1) {
const p = {
value,
setValueAtTime: vi.fn((v: number) => {
p.value = v;
}),
linearRampToValueAtTime: vi.fn((v: number) => {
p.value = v;
}),
cancelScheduledValues: vi.fn()
};
return p;
}
/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */
function makeCtx() {
const graph = {
gains: [] as { gain: ReturnType<typeof makeParam>; dests: unknown[] }[],
sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[] }[],
panners: [] as { pan: ReturnType<typeof makeParam>; dests: unknown[] }[],
destination: { destination: true }
};
const connect = (dests: unknown[]) => (dest: unknown) => {
dests.push(dest);
return dest;
};
const ctx = {
currentTime: 0,
state: 'running',
destination: graph.destination,
resume: vi.fn(async () => undefined),
createGain: () => {
const node: { gain: ReturnType<typeof makeParam>; dests: unknown[]; connect: (d: unknown) => unknown } = {
gain: makeParam(1),
dests: [],
connect: () => undefined
};
node.connect = connect(node.dests);
graph.gains.push(node);
return node;
},
createBufferSource: () => {
const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[]; connect: (d: unknown) => unknown } = {
buffer: null,
loop: false,
playbackRate: makeParam(1),
start: vi.fn(),
stop: vi.fn(),
dests: [],
connect: () => undefined
};
node.connect = connect(node.dests);
graph.sources.push(node);
return node;
},
createStereoPanner: () => {
const node: { pan: ReturnType<typeof makeParam>; dests: unknown[]; connect: (d: unknown) => unknown } = {
pan: makeParam(0),
dests: [],
connect: () => undefined
};
node.connect = connect(node.dests);
graph.panners.push(node);
return node;
},
decodeAudioData: vi.fn(async () => ({ decoded: true }))
};
return { ctx, graph };
}
function makeManager() {
const { ctx, graph } = makeCtx();
const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext);
return { audio, graph };
}
describe('AudioManager', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })));
vi.useFakeTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
expect(graph.gains).toHaveLength(4);
const [master, music, sfx, ambience] = graph.gains;
expect(master!.dests).toContain(graph.destination);
for (const bus of [music, sfx, ambience]) {
expect(bus!.dests).toContain(master);
}
});
it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => {
const { audio, graph } = makeManager();
expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow();
expect(graph.gains).toHaveLength(0);
await audio.unlock();
audio.setBusVolume('sfx', 0.3);
audio.setBusVolume('ambience', 0.4);
expect(graph.gains[2]!.gain.value).toBe(0.3);
expect(graph.gains[3]!.gain.value).toBe(0.4);
});
it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
await audio.play('bell', 0.5);
await audio.play('bell', { volume: 0.5 });
const sfxBus = graph.gains[2]!;
for (const src of graph.sources) {
const gainNode = src.dests[0] as { gain: ReturnType<typeof makeParam>; dests: unknown[] };
expect(gainNode.gain.value).toBe(0.5);
expect(gainNode.dests).toContain(sfxBus);
}
});
it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('bell');
await audio.play('bell', { rate: 1.5, pan: 0.7 });
expect(graph.sources[0]!.playbackRate.value).toBe(1.5);
expect(graph.panners).toHaveLength(1);
expect(graph.panners[0]!.pan.value).toBe(0.7);
await audio.play('bell');
expect(graph.panners).toHaveLength(1); // второй sfx без панорамы
expect(graph.sources[1]!.playbackRate.value).toBe(1);
});
it('незагруженный ключ — тихо ничего, onPlayed не зовётся', async () => {
const { audio } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await expect(audio.play('ghost')).resolves.toBeUndefined();
expect(played).toHaveLength(0);
});
it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => {
const { audio, graph } = makeManager();
const played: { key: string; opts: PlayOptions }[] = [];
audio.onPlayed = (key, opts) => played.push({ key, opts });
await audio.unlock();
await audio.load('bell');
await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 });
await audio.play('bell', 0.5);
expect(played).toEqual([
{ key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } },
{ key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } }
]);
expect(graph.sources).toHaveLength(2);
});
it('setVolume хендла сразу задаёт громкость слота', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
const handle = await audio.playLoop('m', { fade: 0, volume: 1 });
handle!.setVolume(0.3);
const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain;
expect(slotGain.value).toBeCloseTo(0.3);
});
it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
await audio.load('a');
const music = await audio.playMusic('m', { fade: 0 });
const amb = await audio.playAmbience('a', { fade: 0 });
expect(music).not.toBeNull();
expect(amb).not.toBeNull();
expect(graph.sources).toHaveLength(2);
music!.stop(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).toHaveBeenCalled();
expect(graph.sources[1]!.stop).not.toHaveBeenCalled();
});
it('playAmbience дважды — прежний слот остановлен', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('a');
const first = await audio.playAmbience('a', { fade: 0 });
await audio.playAmbience('a', { fade: 0 });
vi.advanceTimersByTime(100);
expect(first).not.toBeNull();
expect(graph.sources[0]!.stop).toHaveBeenCalled();
expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет
});
it('playMusic с негодным ключом не глушит текущий трек', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
await audio.playMusic('m', { fade: 0 });
const bad = await audio.playMusic('ghost', { fade: 0 });
expect(bad).toBeNull();
expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
});
it('playLoop играет в заданную шину и не занимает слот музыки', async () => {
const { audio, graph } = makeManager();
await audio.unlock();
await audio.load('m');
const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 });
expect(layer).not.toBeNull();
const sfxBus = graph.gains[2]!;
expect(sfxBus.dests).not.toContain(graph.destination);
// gain лупа ведёт в sfx
const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
expect(gainNode.dests).toContain(sfxBus);
// музыка стартует отдельно, луп не заглушается
await audio.playMusic('m', { fade: 0 });
expect(graph.sources).toHaveLength(2);
audio.stopMusic(0);
vi.advanceTimersByTime(100);
expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
expect(graph.sources[1]!.stop).toHaveBeenCalled();
});
});