412 lines
15 KiB
TypeScript
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()],
|
|
});
|
|
});
|
|
});
|