import { describe, expect, it, vi } from 'vitest';
import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine';
import { AudioSystem, type AudioSystemDeps } from '../AudioSystem';
/** Фейковый AudioManager: записывает вызовы play/playLoop. */
function fakeAudio() {
const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = [];
const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = [];
const audio = {
play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => {
played.push({ key, opts });
}),
playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => {
loops.push({ key, opts });
return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
})
} as unknown as AudioManager;
return { audio, played, loops };
}
function makeDeps(over: Partial<AudioSystemDeps> = {}) {
const calls = fakeAudio();
const events = new EventBus();
const deps: AudioSystemDeps = {
audio: calls.audio,
events,
getListener: () => ({ x: 10, y: 10 }),
...over
};
return { deps, events, calls };
}
describe('AudioSystem.playAt — затухание и панорама', () => {
it('рядом с героем — полная громкость, без pan (герой в центре)', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playAt('sfx/bell_hit', { x: 10, y: 10 }, 0.8);
expect(calls.played).toHaveLength(1);
expect(calls.played[0]!.key).toBe('sfx/bell_hit');
expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.8);
expect(calls.played[0]!.opts!.pan).toBeUndefined(); // |pan| < 0.01 — без узла
});
it('за радиусом — тишина (вызова нет)', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playAt('sfx/spit', { x: 30, y: 30 }, 0.6, 12);
expect(calls.played).toHaveLength(0);
});
it('сбоку от слушателя — панорама со знаком, клэмп ±0.8', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playAt('sfx/bell_low', { x: 12, y: 10 }, 0.9);
const pan = calls.played[0]!.opts!.pan!;
expect(pan).toBeGreaterThan(0);
expect(pan).toBeLessThanOrEqual(0.8);
});
it('далеко сбоку — панорама клэмпится к 0.8', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playAt('sfx/bell_low', { x: 18, y: 2 }, 2); // далеко и сбоку, громкий
expect(calls.played[0]!.opts!.pan).toBe(0.8);
});
});
describe('AudioSystem.playStep — джиттер и полифония', () => {
it('шагу задаётся rate 0.92..1.08', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playStep('sfx/step', { x: 10, y: 10 }, 0.35);
const rate = calls.played[0]!.opts!.rate!;
expect(rate).toBeGreaterThanOrEqual(0.92);
expect(rate).toBeLessThanOrEqual(1.08);
});
it('один ключ чаще 0.08 с не запускается, другой — проходит', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.playAt('sfx/spit', { x: 10, y: 10 });
sys.playAt('sfx/spit', { x: 10, y: 10 }); // слишком рано — глушится
sys.update(0.05);
sys.playAt('sfx/spit', { x: 10, y: 10 }); // всё ещё рано
sys.update(0.05);
sys.playAt('sfx/bell_hit', { x: 10, y: 10 }); // другой ключ — ок
expect(calls.played.map((p) => p.key)).toEqual(['sfx/spit', 'sfx/bell_hit']);
});
});
describe('AudioSystem.attach — звук мира подпиской', () => {
it('combat:attack cone → bell_hit, resonance → bell_low; spit/awake/kill/playerHit', () => {
const { deps, events, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.attach();
events.emit('combat:attack', { mode: 'cone', origin: { x: 10, y: 10 } });
events.emit('combat:attack', { mode: 'resonance', origin: { x: 10, y: 10 } });
events.emit('combat:spit', { pos: { x: 10, y: 10 } });
events.emit('combat:awake', { kind: 'crawler', pos: { x: 10, y: 10 } });
events.emit('combat:kill', { kind: 'crawler', tile: { x: 10, y: 10 } });
events.emit('combat:playerHit', { hp: 5 });
expect(calls.played.map((p) => p.key)).toEqual([
'sfx/bell_hit',
'sfx/bell_low',
'sfx/spit',
'sfx/ash_hiss',
'sfx/ash_die',
'sfx/hurt'
]);
// playerHit — урон герою, без панорамы
expect(calls.played[5]!.opts!.pan).toBeUndefined();
});
it('off() снимает подписки', () => {
const { deps, events, calls } = makeDeps();
const sys = new AudioSystem(deps);
const off = sys.attach();
off();
events.emit('combat:spit', { pos: { x: 10, y: 10 } });
expect(calls.played).toHaveLength(0);
});
});
describe('AudioSystem.setLayer — локальный амбиент', () => {
it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.setLayer({ key: 'ambience/ponds_water' });
sys.setLayer({ key: 'ambience/ponds_water' }); // тот же ключ — no-op
expect(calls.loops).toHaveLength(1);
expect(calls.loops[0]!.key).toBe('ambience/ponds_water');
expect(calls.loops[0]!.opts!.bus).toBe('ambience');
});
it('setLayer(null) останавливает слой, смена ключа — стоп прежнего', () => {
const { deps, calls } = makeDeps();
const sys = new AudioSystem(deps);
sys.setLayer({ key: 'ambience/tower_hum' });
sys.setLayer({ key: 'ambience/ponds_water' });
expect(calls.loops).toHaveLength(2);
// прежний хендлер получил stop
const firstHandle = (calls.audio.playLoop as ReturnType<typeof vi.fn>).mock.results[0]!.value as Promise<MusicHandle>;
void firstHandle; // хендлер внутри then — проверяем через setLayer(null)
sys.setLayer(null);
// после null слой снят: следующий вызов снова стартует луп
sys.setLayer({ key: 'ambience/tower_hum' });
expect(calls.loops).toHaveLength(3);
});
it('update доводит громкость слоя к цели', async () => {
const { deps } = makeDeps();
const sys = new AudioSystem(deps);
let handle: MusicHandle | null = null;
(deps.audio.playLoop as ReturnType<typeof vi.fn>).mockImplementation(async () => {
handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
return handle;
});
sys.setLayer({ key: 'ambience/tower_hum', volume: 0.5, fade: 1 });
await Promise.resolve(); // луп стартовал
sys.update(0.5);
expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25));
sys.update(0.5);
expect(handle!.setVolume).toHaveBeenCalledWith(0.5);
});
});