mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Add settings to enable/disable quick actions in both the editor context menu and toolbar. Implement dynamic registration system that updates when settings change. Include event-driven architecture for settings changes and improve toolbar injection logic. Update dependencies to latest versions.
543 lines
22 KiB
TypeScript
543 lines
22 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { SettingsService, type IVaultkeeperAISettings } from '../../Services/SettingsService';
|
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
|
import { Services } from '../../Services/Services';
|
|
import { AIProvider, AIProviderModel, fromModel } from '../../Enums/ApiProvider';
|
|
|
|
describe('SettingsService', () => {
|
|
let settingsService: SettingsService;
|
|
let mockPlugin: any;
|
|
|
|
beforeEach(() => {
|
|
// Mock VaultkeeperAIPlugin
|
|
mockPlugin = {
|
|
saveData: vi.fn().mockResolvedValue(undefined)
|
|
};
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
|
|
});
|
|
|
|
afterEach(() => {
|
|
DeregisterAllServices();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Constructor and Initialization', () => {
|
|
it('should initialize with default settings when no loaded settings provided', () => {
|
|
settingsService = new SettingsService({});
|
|
|
|
expect(settingsService.settings.firstTimeStart).toBe(true);
|
|
expect(settingsService.settings.model).toBe(AIProviderModel.ClaudeHaiku_4_5);
|
|
expect(settingsService.settings.apiKeys).toEqual({
|
|
claude: '',
|
|
openai: '',
|
|
gemini: '', mistral: ''
|
|
});
|
|
expect(settingsService.settings.exclusions).toEqual([]);
|
|
expect(settingsService.settings.userInstruction).toBe('');
|
|
expect(settingsService.settings.searchResultsLimit).toBe(30);
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(100);
|
|
});
|
|
|
|
it('should merge loaded settings with defaults', () => {
|
|
const loadedSettings: Partial<IVaultkeeperAISettings> = {
|
|
firstTimeStart: false,
|
|
provider: AIProvider.Gemini,
|
|
model: AIProviderModel.GeminiFlash_2_5,
|
|
planningModel: AIProviderModel.GeminiPro_2_5,
|
|
apiKeys: {
|
|
claude: 'claude-key-123',
|
|
openai: 'openai-key-456',
|
|
gemini: 'gemini-key-789', mistral: ''
|
|
},
|
|
searchResultsLimit: 25,
|
|
snippetSizeLimit: 200
|
|
};
|
|
|
|
settingsService = new SettingsService(loadedSettings as IVaultkeeperAISettings);
|
|
|
|
expect(settingsService.settings.firstTimeStart).toBe(false);
|
|
expect(settingsService.settings.model).toBe(AIProviderModel.GeminiFlash_2_5);
|
|
expect(settingsService.settings.apiKeys.claude).toBe('claude-key-123');
|
|
expect(settingsService.settings.apiKeys.openai).toBe('openai-key-456');
|
|
expect(settingsService.settings.apiKeys.gemini).toBe('gemini-key-789');
|
|
expect(settingsService.settings.searchResultsLimit).toBe(25);
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(200);
|
|
});
|
|
|
|
it('should handle partially loaded settings and fill missing properties with defaults', () => {
|
|
const loadedSettings: Partial<IVaultkeeperAISettings> = {
|
|
provider: AIProvider.OpenAI,
|
|
model: AIProviderModel.GPT_5_4,
|
|
apiKeys: {
|
|
claude: '',
|
|
openai: 'partial-key',
|
|
gemini: '', mistral: ''
|
|
}
|
|
};
|
|
|
|
settingsService = new SettingsService(loadedSettings as IVaultkeeperAISettings);
|
|
|
|
expect(settingsService.settings.firstTimeStart).toBe(true); // Default
|
|
expect(settingsService.settings.model).toBe(AIProviderModel.GPT_5_4); // Loaded
|
|
expect(settingsService.settings.apiKeys.openai).toBe('partial-key'); // Loaded
|
|
expect(settingsService.settings.exclusions).toEqual([]); // Default
|
|
expect(settingsService.settings.userInstruction).toBe(''); // Default
|
|
expect(settingsService.settings.searchResultsLimit).toBe(30); // Default
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(100); // Default
|
|
});
|
|
});
|
|
|
|
describe('getApiKeyForProvider', () => {
|
|
beforeEach(() => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
planningModel: AIProviderModel.ClaudeOpus_4,
|
|
apiKeys: {
|
|
claude: 'claude-api-key',
|
|
openai: 'openai-api-key',
|
|
gemini: 'gemini-api-key', mistral: ''
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Claude,
|
|
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
});
|
|
|
|
it('should return Claude API key for Claude provider', () => {
|
|
const key = settingsService.getApiKeyForProvider(AIProvider.Claude);
|
|
expect(key).toBe('claude-api-key');
|
|
});
|
|
|
|
it('should return OpenAI API key for OpenAI provider', () => {
|
|
const key = settingsService.getApiKeyForProvider(AIProvider.OpenAI);
|
|
expect(key).toBe('openai-api-key');
|
|
});
|
|
|
|
it('should return Gemini API key for Gemini provider', () => {
|
|
const key = settingsService.getApiKeyForProvider(AIProvider.Gemini);
|
|
expect(key).toBe('gemini-api-key');
|
|
});
|
|
|
|
it('should return empty string when no API key is set', () => {
|
|
settingsService.settings.apiKeys.claude = '';
|
|
const key = settingsService.getApiKeyForProvider(AIProvider.Claude);
|
|
expect(key).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('getApiKeyForCurrentModel', () => {
|
|
it('should return Claude key when current model is Claude', () => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
planningModel: AIProviderModel.ClaudeOpus_4,
|
|
apiKeys: {
|
|
claude: 'claude-key',
|
|
openai: 'openai-key',
|
|
gemini: 'gemini-key', mistral: ''
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Claude,
|
|
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
|
|
const key = settingsService.getApiKeyForCurrentModel();
|
|
expect(key).toBe('claude-key');
|
|
});
|
|
|
|
it('should return OpenAI key when current model is GPT', () => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.GPT_5_4_Mini,
|
|
planningModel: AIProviderModel.GPT_5_4,
|
|
apiKeys: {
|
|
claude: 'claude-key',
|
|
openai: 'openai-key',
|
|
gemini: 'gemini-key', mistral: ''
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.OpenAI,
|
|
quickActionModel: AIProviderModel.GPT_5_4_Mini,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
|
|
const key = settingsService.getApiKeyForCurrentModel();
|
|
expect(key).toBe('openai-key');
|
|
});
|
|
|
|
it('should return Gemini key when current model is Gemini', () => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.GeminiFlash_2_5,
|
|
planningModel: AIProviderModel.GeminiPro_2_5,
|
|
apiKeys: {
|
|
claude: 'claude-key',
|
|
openai: 'openai-key',
|
|
gemini: 'gemini-key', mistral: ''
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Gemini,
|
|
quickActionModel: AIProviderModel.GeminiFlash_2_5,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
|
|
const key = settingsService.getApiKeyForCurrentModel();
|
|
expect(key).toBe('gemini-key');
|
|
});
|
|
|
|
it('should use fromModel to determine provider', () => {
|
|
// Test with various Claude models
|
|
settingsService = new SettingsService({
|
|
provider: AIProvider.Claude,
|
|
model: AIProviderModel.ClaudeOpus_4,
|
|
apiKeys: { claude: 'opus-key', openai: '', gemini: '', mistral: '' }
|
|
});
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('opus-key');
|
|
|
|
// Test with various Gemini models
|
|
settingsService = new SettingsService({
|
|
provider: AIProvider.Gemini,
|
|
model: AIProviderModel.GeminiPro_2_5,
|
|
apiKeys: { claude: '', openai: '', gemini: 'pro-key', mistral: '' }
|
|
});
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('pro-key');
|
|
|
|
// Test with various GPT models
|
|
settingsService = new SettingsService({
|
|
provider: AIProvider.OpenAI,
|
|
model: AIProviderModel.GPT_5_4,
|
|
apiKeys: { claude: '', openai: 'gpt5-key', gemini: '', mistral: '' }
|
|
});
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('gpt5-key');
|
|
});
|
|
});
|
|
|
|
describe('setApiKeyForProvider', () => {
|
|
beforeEach(() => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
planningModel: AIProviderModel.ClaudeOpus_4,
|
|
apiKeys: {
|
|
claude: '',
|
|
openai: '',
|
|
gemini: '', mistral: ''
|
|
},
|
|
exclusions: [],
|
|
userInstruction: '',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Claude,
|
|
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
});
|
|
|
|
it('should update Claude API key', () => {
|
|
settingsService.setApiKeyForProvider(AIProvider.Claude, 'new-claude-key');
|
|
expect(settingsService.settings.apiKeys.claude).toBe('new-claude-key');
|
|
});
|
|
|
|
it('should update OpenAI API key', () => {
|
|
settingsService.setApiKeyForProvider(AIProvider.OpenAI, 'new-openai-key');
|
|
expect(settingsService.settings.apiKeys.openai).toBe('new-openai-key');
|
|
});
|
|
|
|
it('should update Gemini API key', () => {
|
|
settingsService.setApiKeyForProvider(AIProvider.Gemini, 'new-gemini-key');
|
|
expect(settingsService.settings.apiKeys.gemini).toBe('new-gemini-key');
|
|
});
|
|
|
|
it('should not affect other provider keys when updating one', () => {
|
|
settingsService.settings.apiKeys = {
|
|
claude: 'existing-claude',
|
|
openai: 'existing-openai',
|
|
gemini: 'existing-gemini', mistral: ''
|
|
};
|
|
|
|
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-claude');
|
|
|
|
expect(settingsService.settings.apiKeys.claude).toBe('updated-claude');
|
|
expect(settingsService.settings.apiKeys.openai).toBe('existing-openai');
|
|
expect(settingsService.settings.apiKeys.gemini).toBe('existing-gemini');
|
|
});
|
|
|
|
it('should allow setting empty string as API key', () => {
|
|
settingsService.settings.apiKeys.claude = 'some-key';
|
|
settingsService.setApiKeyForProvider(AIProvider.Claude, '');
|
|
expect(settingsService.settings.apiKeys.claude).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('saveSettings', () => {
|
|
beforeEach(() => {
|
|
const loadedSettings: IVaultkeeperAISettings = {
|
|
firstTimeStart: false,
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
planningModel: AIProviderModel.ClaudeOpus_4,
|
|
apiKeys: {
|
|
claude: 'test-key',
|
|
openai: '',
|
|
gemini: '', mistral: ''
|
|
},
|
|
exclusions: ['node_modules'],
|
|
userInstruction: 'Be helpful',
|
|
searchResultsLimit: 15,
|
|
snippetSizeLimit: 300,
|
|
enableMemories: false,
|
|
allowUpdatingMemories: true,
|
|
enableWebSearch: true,
|
|
enableWebViewer: false,
|
|
provider: AIProvider.Claude,
|
|
quickActionModel: AIProviderModel.ClaudeSonnet_4_6,
|
|
enableContextMenuActions: false,
|
|
enableToolbarActions: false
|
|
};
|
|
settingsService = new SettingsService(loadedSettings);
|
|
});
|
|
|
|
it('should call plugin.saveData with current settings', async () => {
|
|
await settingsService.saveSettings();
|
|
|
|
expect(mockPlugin.saveData).toHaveBeenCalledWith(settingsService.settings);
|
|
expect(mockPlugin.saveData).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should call plugin.saveData with updated settings after modification', async () => {
|
|
settingsService.setApiKeyForProvider(AIProvider.Claude, 'updated-key');
|
|
settingsService.settings.userInstruction = 'Updated instruction';
|
|
|
|
await settingsService.saveSettings();
|
|
|
|
expect(mockPlugin.saveData).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKeys: expect.objectContaining({
|
|
claude: 'updated-key'
|
|
}),
|
|
userInstruction: 'Updated instruction'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle saveData errors gracefully', async () => {
|
|
mockPlugin.saveData.mockRejectedValue(new Error('Save failed'));
|
|
|
|
await expect(settingsService.saveSettings()).rejects.toThrow('Save failed');
|
|
});
|
|
});
|
|
|
|
describe('Provider Detection from Model Names', () => {
|
|
it('should correctly identify Claude models', () => {
|
|
const claudeModels = [
|
|
AIProviderModel.ClaudeSonnet_4_5,
|
|
AIProviderModel.ClaudeSonnet_4,
|
|
AIProviderModel.ClaudeOpus_4,
|
|
AIProviderModel.ClaudeHaiku_4_5
|
|
];
|
|
|
|
claudeModels.forEach(model => {
|
|
settingsService = new SettingsService({
|
|
model,
|
|
apiKeys: { claude: 'test-claude', openai: '', gemini: '', mistral: '' }
|
|
});
|
|
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-claude');
|
|
});
|
|
});
|
|
|
|
it('should correctly identify Gemini models', () => {
|
|
const geminiModels = [
|
|
AIProviderModel.GeminiFlash_2_5,
|
|
AIProviderModel.GeminiFlash_2_5_Lite,
|
|
AIProviderModel.GeminiPro_2_5
|
|
];
|
|
|
|
geminiModels.forEach(model => {
|
|
settingsService = new SettingsService({
|
|
provider: AIProvider.Gemini,
|
|
model,
|
|
apiKeys: { claude: '', openai: '', gemini: 'test-gemini', mistral: '' }
|
|
});
|
|
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-gemini');
|
|
});
|
|
});
|
|
|
|
it('should correctly identify OpenAI models', () => {
|
|
const openaiModels = [
|
|
AIProviderModel.GPT_5_4_Nano,
|
|
AIProviderModel.GPT_5_4_Mini,
|
|
AIProviderModel.GPT_5_4,
|
|
AIProviderModel.GPT_5_4_Mini
|
|
];
|
|
|
|
openaiModels.forEach(model => {
|
|
settingsService = new SettingsService({
|
|
provider: AIProvider.OpenAI,
|
|
model,
|
|
apiKeys: { claude: '', openai: 'test-openai', gemini: '', mistral: '' }
|
|
});
|
|
|
|
expect(settingsService.getApiKeyForCurrentModel()).toBe('test-openai');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Settings Immutability and Reference', () => {
|
|
it('should maintain reference to settings object', () => {
|
|
settingsService = new SettingsService({
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
apiKeys: { claude: 'key', openai: '', gemini: '', mistral: '' }
|
|
});
|
|
|
|
const settingsRef = settingsService.settings;
|
|
settingsService.setApiKeyForProvider(AIProvider.Claude, 'new-key');
|
|
|
|
// The reference should still point to the same object
|
|
expect(settingsRef.apiKeys.claude).toBe('new-key');
|
|
});
|
|
|
|
it('should allow direct modification of settings properties', () => {
|
|
settingsService = new SettingsService({
|
|
model: AIProviderModel.ClaudeSonnet_4_5,
|
|
apiKeys: { claude: '', openai: '', gemini: '', mistral: '' },
|
|
exclusions: []
|
|
});
|
|
|
|
settingsService.settings.exclusions.push('test-exclusion');
|
|
expect(settingsService.settings.exclusions).toContain('test-exclusion');
|
|
|
|
settingsService.settings.userInstruction = 'Direct modification';
|
|
expect(settingsService.settings.userInstruction).toBe('Direct modification');
|
|
});
|
|
});
|
|
|
|
describe('Search and Snippet Limit Settings', () => {
|
|
it('should use default searchResultsLimit when not specified', () => {
|
|
settingsService = new SettingsService({});
|
|
expect(settingsService.settings.searchResultsLimit).toBe(30);
|
|
});
|
|
|
|
it('should use default snippetSizeLimit when not specified', () => {
|
|
settingsService = new SettingsService({});
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(100);
|
|
});
|
|
|
|
it('should allow custom searchResultsLimit values', () => {
|
|
settingsService = new SettingsService({
|
|
searchResultsLimit: 30
|
|
});
|
|
expect(settingsService.settings.searchResultsLimit).toBe(30);
|
|
});
|
|
|
|
it('should allow custom snippetSizeLimit values', () => {
|
|
settingsService = new SettingsService({
|
|
snippetSizeLimit: 300
|
|
});
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(300);
|
|
});
|
|
|
|
it('should allow zero values for searchResultsLimit', () => {
|
|
settingsService = new SettingsService({
|
|
searchResultsLimit: 0
|
|
});
|
|
expect(settingsService.settings.searchResultsLimit).toBe(0);
|
|
});
|
|
|
|
it('should allow zero values for snippetSizeLimit', () => {
|
|
settingsService = new SettingsService({
|
|
snippetSizeLimit: 0
|
|
});
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(0);
|
|
});
|
|
|
|
it('should allow direct modification of searchResultsLimit', () => {
|
|
settingsService = new SettingsService({});
|
|
settingsService.settings.searchResultsLimit = 50;
|
|
expect(settingsService.settings.searchResultsLimit).toBe(50);
|
|
});
|
|
|
|
it('should allow direct modification of snippetSizeLimit', () => {
|
|
settingsService = new SettingsService({});
|
|
settingsService.settings.snippetSizeLimit = 500;
|
|
expect(settingsService.settings.snippetSizeLimit).toBe(500);
|
|
});
|
|
|
|
it('should persist searchResultsLimit and snippetSizeLimit when saving settings', async () => {
|
|
settingsService = new SettingsService({
|
|
searchResultsLimit: 20,
|
|
snippetSizeLimit: 250
|
|
});
|
|
|
|
await settingsService.saveSettings();
|
|
|
|
expect(mockPlugin.saveData).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
searchResultsLimit: 20,
|
|
snippetSizeLimit: 250
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should handle modified limits in saveSettings', async () => {
|
|
settingsService = new SettingsService({});
|
|
|
|
settingsService.settings.searchResultsLimit = 100;
|
|
settingsService.settings.snippetSizeLimit = 600;
|
|
|
|
await settingsService.saveSettings();
|
|
|
|
expect(mockPlugin.saveData).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
searchResultsLimit: 100,
|
|
snippetSizeLimit: 600
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|