improve: tests
This commit is contained in:
+141
-13
@@ -1,5 +1,5 @@
|
||||
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
||||
import { sep } from 'node:path';
|
||||
import { resolve } from 'node:path';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import AudioManager from '../src/audio-manager';
|
||||
@@ -59,11 +59,14 @@ const connectionOptions: VoiceConnectionOptions = {
|
||||
adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'],
|
||||
};
|
||||
|
||||
const source: AudioSource = {
|
||||
const liveStreamSource: AudioSource = {
|
||||
type: 'url',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
url: 'https://synradiode.stream.laut.fm/synradiode',
|
||||
};
|
||||
|
||||
const fileSourcePath = 'tests/audio.mp3';
|
||||
const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath);
|
||||
|
||||
describe('AudioManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -82,7 +85,7 @@ describe('AudioManager', () => {
|
||||
it('connects and starts playback with resolved URL source', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
@@ -92,23 +95,91 @@ describe('AudioManager', () => {
|
||||
expect(manager.isConnected).toBe(true);
|
||||
expect(manager.isPlaying).toBe(true);
|
||||
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
|
||||
expect(startFfmpeg).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined);
|
||||
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
|
||||
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
|
||||
});
|
||||
|
||||
it('resolves file sources against the current working directory', async () => {
|
||||
it('starts playback with the committed mp3 test file', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: {
|
||||
type: 'file',
|
||||
path: 'audio/song.mp3',
|
||||
path: fileSourcePath,
|
||||
},
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(startFfmpeg).toHaveBeenCalledWith(expect.stringContaining(`audio${sep}song.mp3`), undefined);
|
||||
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
|
||||
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
|
||||
});
|
||||
|
||||
it('accepts the live stream URL as a valid source', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.connect();
|
||||
await manager.play();
|
||||
|
||||
expect(startFfmpeg).toHaveBeenCalledWith('https://synradiode.stream.laut.fm/synradiode', undefined);
|
||||
});
|
||||
|
||||
it('plays a source passed directly to play()', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.connect();
|
||||
await manager.play({
|
||||
type: 'file',
|
||||
path: fileSourcePath,
|
||||
});
|
||||
|
||||
expect(startFfmpeg).toHaveBeenCalledWith(resolvedFileSourcePath, undefined);
|
||||
expect(manager.state).toBe('playing');
|
||||
});
|
||||
|
||||
it('replaces active playback when a new source is played', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.start();
|
||||
await manager.play({
|
||||
type: 'file',
|
||||
path: fileSourcePath,
|
||||
});
|
||||
|
||||
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
|
||||
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(startFfmpeg).toHaveBeenLastCalledWith(resolvedFileSourcePath, undefined);
|
||||
});
|
||||
|
||||
it('requires an active connection before play()', async () => {
|
||||
const manager = new AudioManager({
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await expect(manager.play()).rejects.toThrow(AudioManagerStateError);
|
||||
});
|
||||
|
||||
it('requires a source before play()', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
await manager.connect();
|
||||
|
||||
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
|
||||
});
|
||||
|
||||
it('rejects invalid URL sources', async () => {
|
||||
@@ -129,7 +200,7 @@ describe('AudioManager', () => {
|
||||
it('applies initial volume only when volume support is enabled', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
volume: {
|
||||
enabled: true,
|
||||
@@ -148,10 +219,37 @@ describe('AudioManager', () => {
|
||||
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
|
||||
});
|
||||
|
||||
it('rejects invalid volume percentages', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
volume: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
await manager.start();
|
||||
|
||||
expect(() => manager.setVolume(-1)).toThrow(AudioManagerConfigError);
|
||||
expect(() => manager.setVolume(101)).toThrow(AudioManagerConfigError);
|
||||
});
|
||||
|
||||
it('rejects volume changes before any resource exists', () => {
|
||||
const manager = new AudioManager({
|
||||
renewIntervalMs: false,
|
||||
volume: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
|
||||
});
|
||||
|
||||
it('pauses and resumes only from valid playback states', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
@@ -169,7 +267,7 @@ describe('AudioManager', () => {
|
||||
it('stops playback and voice resources idempotently', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
@@ -183,12 +281,29 @@ describe('AudioManager', () => {
|
||||
expect(manager.state).toBe('stopped');
|
||||
});
|
||||
|
||||
it('restarts playback when the renewal timer fires', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: 10_000,
|
||||
});
|
||||
const startSpy = jest.spyOn(manager, 'start').mockResolvedValue(undefined);
|
||||
|
||||
await manager.connect();
|
||||
jest.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears existing renewal timer before reconnecting', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: 10_000,
|
||||
});
|
||||
|
||||
@@ -200,10 +315,23 @@ describe('AudioManager', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks source and connection changes after disposal', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
manager.dispose();
|
||||
|
||||
expect(() => manager.setSource(liveStreamSource)).toThrow(AudioManagerStateError);
|
||||
expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError);
|
||||
});
|
||||
|
||||
it('prevents use after disposal', async () => {
|
||||
const manager = new AudioManager({
|
||||
connection: connectionOptions,
|
||||
source,
|
||||
source: liveStreamSource,
|
||||
renewIntervalMs: false,
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';
|
||||
|
||||
jest.mock('node:child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpawn = jest.mocked(spawn);
|
||||
|
||||
type MockChildProcess = {
|
||||
stdout: {
|
||||
destroy: jest.Mock;
|
||||
};
|
||||
stderr: {
|
||||
destroy: jest.Mock;
|
||||
resume: jest.Mock;
|
||||
};
|
||||
once: jest.Mock;
|
||||
removeAllListeners: jest.Mock;
|
||||
kill: jest.Mock;
|
||||
killed: boolean;
|
||||
exitCode: number | null;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
function createMockChildProcess(): MockChildProcess {
|
||||
return {
|
||||
stdout: {
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
stderr: {
|
||||
destroy: jest.fn(),
|
||||
resume: jest.fn(),
|
||||
},
|
||||
once: jest.fn(),
|
||||
removeAllListeners: jest.fn(),
|
||||
kill: jest.fn(),
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ffmpeg helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('prefers an explicit executable path', () => {
|
||||
expect(resolveFfmpegExecutable({ executablePath: '/custom/ffmpeg' })).toBe('/custom/ffmpeg');
|
||||
});
|
||||
|
||||
it('uses the native ffmpeg binary by default', () => {
|
||||
expect(resolveFfmpegExecutable()).toBe('ffmpeg');
|
||||
expect(resolveFfmpegExecutable({ mode: 'native' })).toBe('ffmpeg');
|
||||
});
|
||||
|
||||
it('spawns ffmpeg with the default argument set', () => {
|
||||
const childProcess = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(childProcess);
|
||||
|
||||
startFfmpeg('https://synradiode.stream.laut.fm/synradiode');
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
[
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-nostdin',
|
||||
'-i',
|
||||
'https://synradiode.stream.laut.fm/synradiode',
|
||||
'-vn',
|
||||
'-f',
|
||||
's16le',
|
||||
'-ar',
|
||||
'48000',
|
||||
'-ac',
|
||||
'2',
|
||||
'pipe:1',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
|
||||
expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses custom executable and argument overrides when provided', () => {
|
||||
const childProcess = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(childProcess);
|
||||
|
||||
startFfmpeg('tests/audio.mp3', {
|
||||
executablePath: '/custom/ffmpeg',
|
||||
inputArgs: ['-re'],
|
||||
outputArgs: ['-f', 'wav', 'pipe:1'],
|
||||
});
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'/custom/ffmpeg',
|
||||
['-re', '-i', 'tests/audio.mp3', '-f', 'wav', 'pipe:1'],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
});
|
||||
|
||||
it('stops a running child process and schedules a force kill fallback', () => {
|
||||
jest.useFakeTimers();
|
||||
const childProcess = createMockChildProcess();
|
||||
mockSpawn.mockReturnValue(childProcess);
|
||||
|
||||
const handle = startFfmpeg('tests/audio.mp3');
|
||||
handle.stop();
|
||||
|
||||
expect(childProcess.stdout.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(childProcess.stderr.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(childProcess.removeAllListeners).toHaveBeenCalledTimes(1);
|
||||
expect(childProcess.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
|
||||
jest.advanceTimersByTime(2_000);
|
||||
|
||||
expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
|
||||
});
|
||||
|
||||
it('does not signal a process that already exited', () => {
|
||||
const childProcess = createMockChildProcess();
|
||||
childProcess.exitCode = 0;
|
||||
mockSpawn.mockReturnValue(childProcess);
|
||||
|
||||
const handle = startFfmpeg('tests/audio.mp3');
|
||||
handle.stop();
|
||||
|
||||
expect(childProcess.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user