Files
FemScoreboard/discord/__tests__/voicemsg.test.ts

412 lines
15 KiB
TypeScript

/**
* Tests for commands/voicemsg/voicemsg.ts
*/
jest.mock('discord.js', () => {
const actual = jest.requireActual('discord.js');
return {
...actual,
SlashCommandBuilder: jest.fn().mockImplementation(() => ({
setName: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
addStringOption: jest.fn().mockReturnThis(),
})),
EmbedBuilder: jest.fn().mockImplementation(() => ({
setColor: jest.fn().mockReturnThis(),
setAuthor: jest.fn().mockReturnThis(),
setDescription: jest.fn().mockReturnThis(),
setFooter: jest.fn().mockReturnThis(),
setTimestamp: jest.fn().mockReturnThis(),
})),
AttachmentBuilder: jest.fn().mockImplementation((buffer, options) => {
const file = { buffer, name: options?.name };
return file;
}),
};
});
jest.mock('../util', () => {
const actual = jest.requireActual('../util');
return {
...actual,
requestTTSResponse: jest.fn(),
};
});
jest.mock('../../logging', () => ({
logError: jest.fn(),
logInfo: jest.fn(),
logWarn: jest.fn(),
}));
const voicemsgModule = require('../commands/voicemsg/voicemsg');
const voicemsgCommand = voicemsgModule.default || voicemsgModule;
const { requestTTSResponse } = require('../util');
const { parseLoadingEmojis, getRandomLoadingEmoji } = require('../commands/helpers');
describe('voicemsg helper functions', () => {
describe('parseLoadingEmojis', () => {
afterEach(() => {
delete process.env.LOADING_EMOJIS;
});
it('should parse emojis from environment variable', () => {
process.env.LOADING_EMOJIS = '<:clueless:123>,<a:hachune:456>,🎵';
const result = parseLoadingEmojis();
expect(result).toEqual(['<:clueless:123>', '<a:hachune:456>', '🎵']);
});
it('should return default emojis when LOADING_EMOJIS is empty', () => {
process.env.LOADING_EMOJIS = '';
const result = parseLoadingEmojis();
expect(result).toEqual(['🤔', '✨', '🎵']);
});
it('should return default emojis when LOADING_EMOJIS is whitespace only', () => {
process.env.LOADING_EMOJIS = ' ';
const result = parseLoadingEmojis();
expect(result).toEqual(['🤔', '✨', '🎵']);
});
it('should handle whitespace in emoji list', () => {
process.env.LOADING_EMOJIS = ' 🤔 , ✨ , 🎵 ';
const result = parseLoadingEmojis();
expect(result).toEqual(['🤔', '✨', '🎵']);
});
it('should filter out empty entries', () => {
process.env.LOADING_EMOJIS = '🤔,,✨,,,';
const result = parseLoadingEmojis();
expect(result).toEqual(['🤔', '✨']);
});
});
describe('getRandomLoadingEmoji', () => {
afterEach(() => {
delete process.env.LOADING_EMOJIS;
});
it('should return a valid emoji from the list', () => {
process.env.LOADING_EMOJIS = '🤔,✨,🎵';
const result = getRandomLoadingEmoji();
expect(['🤔', '✨', '🎵']).toContain(result);
});
it('should return default emoji when LOADING_EMOJIS is empty', () => {
process.env.LOADING_EMOJIS = '';
const result = getRandomLoadingEmoji();
expect(['🤔', '✨', '🎵']).toContain(result);
});
it('should return different emojis on multiple calls', () => {
process.env.LOADING_EMOJIS = '🤔,✨,🎵,🎤,🌸';
const results = new Set();
for (let i = 0; i < 20; i++) {
results.add(getRandomLoadingEmoji());
}
// With 5 emojis and 20 calls, we should get at least 2 different ones
expect(results.size).toBeGreaterThanOrEqual(2);
});
});
});
describe('voicemsg command', () => {
let mockInteraction: {
options: { getString: jest.Mock };
reply: jest.Mock;
editReply: jest.Mock;
client: {
provider: jest.Mock;
llmconf: jest.Mock;
sysprompt: jest.Mock;
};
};
let mockProvider: {
name: jest.Mock;
requestLLMResponse: jest.Mock;
requestStructuredVoiceResponse: jest.Mock;
setModel: jest.Mock;
};
const mockConfig = {
max_new_tokens: 100,
min_new_tokens: 1,
temperature: 0.7,
top_p: 0.9,
frequency_penalty: 0.0,
presence_penalty: 0.0,
msg_context: 8,
};
beforeEach(() => {
jest.clearAllMocks();
mockProvider = {
name: jest.fn().mockReturnValue('OpenAI (gpt-4)'),
requestLLMResponse: jest.fn(),
requestStructuredVoiceResponse: jest.fn(),
setModel: jest.fn(),
};
mockInteraction = {
options: { getString: jest.fn() },
reply: jest.fn(),
editReply: jest.fn(),
client: {
provider: jest.fn().mockReturnValue(mockProvider),
llmconf: jest.fn().mockReturnValue(mockConfig),
sysprompt: jest.fn().mockReturnValue('You are Miku'),
},
};
});
it('should have correct command data structure', () => {
expect(voicemsgCommand.data).toBeDefined();
expect(voicemsgCommand.data.setName).toBeDefined();
expect(voicemsgCommand.execute).toBeDefined();
});
it('should have correct command name and description', () => {
// The mock SlashCommandBuilder returns a chainable object
// We verify the structure exists rather than specific values
expect(voicemsgCommand.data).toBeDefined();
expect(voicemsgCommand.data.setName).toBeDefined();
expect(voicemsgCommand.data.setDescription).toBeDefined();
});
it('should have required text option', () => {
// The command data is built when the module loads
// We just verify the export structure is correct
expect(voicemsgCommand.data).toBeDefined();
expect(voicemsgCommand.execute).toBeDefined();
});
it('should generate voice message with structured response', async () => {
mockInteraction.options.getString.mockReturnValue('Hello Miku!');
mockProvider.requestStructuredVoiceResponse.mockResolvedValue({
message: 'Hello there! Nice to meet you~ ♪',
instruct: 'Speak cheerfully and energetically',
});
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
// Should show initial loading embed
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
// Should call structured response method
expect(mockProvider.requestStructuredVoiceResponse).toHaveBeenCalledWith(
'Hello Miku!',
'You are Miku',
mockConfig
);
// Should generate TTS with instruct
expect(requestTTSResponse).toHaveBeenCalledWith(
'Hello there! Nice to meet you~ ♪',
undefined,
undefined,
'Speak cheerfully and energetically'
);
// Should update with final embed and audio file (called 3 times: thinking, tts, final)
expect(mockInteraction.editReply).toHaveBeenCalledTimes(3);
// Verify the last call includes files (audio attachment)
const lastEditCall = mockInteraction.editReply.mock.calls[2][0];
// The mock EmbedBuilder methods return the mock function, not the embed
// So we just verify editReply was called with an object containing embeds
expect(lastEditCall.embeds).toBeDefined();
expect(mockInteraction.editReply).toHaveBeenCalledWith(
expect.objectContaining({
embeds: expect.anything(),
})
);
});
it('should handle provider without structured output (fallback)', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
// Remove structured method to test fallback
delete mockProvider.requestStructuredVoiceResponse;
mockProvider.requestLLMResponse.mockResolvedValue(
JSON.stringify({
message: 'Fallback response',
instruct: 'Speak normally',
})
);
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
expect(mockProvider.requestLLMResponse).toHaveBeenCalled();
expect(requestTTSResponse).toHaveBeenCalledWith(
'Fallback response',
undefined,
undefined,
'Speak normally'
);
});
it('should handle malformed JSON in fallback', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
delete mockProvider.requestStructuredVoiceResponse;
mockProvider.requestLLMResponse.mockResolvedValue('Invalid JSON response');
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
// Should use fallback defaults
expect(requestTTSResponse).toHaveBeenCalledWith(
'Invalid JSON response',
undefined,
undefined,
'Speak in a friendly and enthusiastic tone'
);
});
it('should handle JSON with markdown code blocks', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
delete mockProvider.requestStructuredVoiceResponse;
mockProvider.requestLLMResponse.mockResolvedValue(
'```json\n{"message": "Parsed response", "instruct": "Speak softly"}\n```'
);
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
expect(requestTTSResponse).toHaveBeenCalledWith(
'Parsed response',
undefined,
undefined,
'Speak softly'
);
});
it('should handle missing message field in JSON response', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
delete mockProvider.requestStructuredVoiceResponse;
mockProvider.requestLLMResponse.mockResolvedValue(
JSON.stringify({
instruct: 'Speak happily',
})
);
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
// Should use the full response as message
expect(requestTTSResponse).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
'Speak happily'
);
});
it('should handle missing instruct field in JSON response', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
delete mockProvider.requestStructuredVoiceResponse;
mockProvider.requestLLMResponse.mockResolvedValue(
JSON.stringify({
message: 'Hello!',
})
);
requestTTSResponse.mockResolvedValue({
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(100)),
});
await voicemsgCommand.execute(mockInteraction);
expect(requestTTSResponse).toHaveBeenCalledWith(
'Hello!',
undefined,
undefined,
'Speak in a friendly and enthusiastic tone'
);
});
it('should handle errors gracefully', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
mockProvider.requestStructuredVoiceResponse.mockRejectedValue(new Error('LLM API error'));
await voicemsgCommand.execute(mockInteraction);
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
expect(mockInteraction.editReply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
});
it('should handle missing provider configuration', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
mockInteraction.client.provider = jest.fn().mockReturnValue(null);
await voicemsgCommand.execute(mockInteraction);
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
expect(mockInteraction.editReply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
});
it('should handle missing llmconf configuration', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
mockInteraction.client.llmconf = jest.fn().mockReturnValue(null);
await voicemsgCommand.execute(mockInteraction);
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
expect(mockInteraction.editReply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
});
it('should handle missing sysprompt configuration', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
mockInteraction.client.sysprompt = jest.fn().mockReturnValue(null);
await voicemsgCommand.execute(mockInteraction);
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
expect(mockInteraction.editReply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
});
it('should handle TTS generation errors', async () => {
mockInteraction.options.getString.mockReturnValue('Test message');
mockProvider.requestStructuredVoiceResponse.mockResolvedValue({
message: 'Hello!',
instruct: 'Speak happily',
});
requestTTSResponse.mockRejectedValue(new Error('TTS service unavailable'));
await voicemsgCommand.execute(mockInteraction);
expect(mockInteraction.reply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
expect(mockInteraction.editReply).toHaveBeenCalledWith({
embeds: [expect.anything()],
});
});
});