UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

378 lines 20.3 kB
import { createInjector } from '@furystack/inject'; import { using, usingAsync } from '@furystack/utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CommandPaletteManager } from './command-palette-manager.js'; const createMockSuggestion = (name, score) => ({ element: { tagName: 'div', textContent: name }, score, onSelected: vi.fn(), }); const createCommandProvider = (suggestions) => { return vi.fn().mockResolvedValue(suggestions); }; describe('CommandPaletteManager', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe('Construction and disposal', () => { it('Should be constructed with command providers', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { expect(manager.isOpened.getValue()).toBe(false); expect(manager.isLoading.getValue()).toBe(false); expect(manager.term.getValue()).toBe(''); expect(manager.selectedIndex.getValue()).toBe(0); expect(manager.currentSuggestions.getValue()).toEqual([]); }); }); it('Should register keyboard listener on construction', () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); const providers = []; using(new CommandPaletteManager(providers), () => { expect(addEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function), true); }); addEventListenerSpy.mockRestore(); }); it('Should dispose all observables and remove event listeners', () => { const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); const providers = []; const manager = new CommandPaletteManager(providers); const isOpenedDisposeSpy = vi.spyOn(manager.isOpened, Symbol.dispose); const isLoadingDisposeSpy = vi.spyOn(manager.isLoading, Symbol.dispose); const termDisposeSpy = vi.spyOn(manager.term, Symbol.dispose); const selectedIndexDisposeSpy = vi.spyOn(manager.selectedIndex, Symbol.dispose); const currentSuggestionsDisposeSpy = vi.spyOn(manager.currentSuggestions, Symbol.dispose); manager[Symbol.dispose](); expect(isOpenedDisposeSpy).toHaveBeenCalled(); expect(isLoadingDisposeSpy).toHaveBeenCalled(); expect(termDisposeSpy).toHaveBeenCalled(); expect(selectedIndexDisposeSpy).toHaveBeenCalled(); expect(currentSuggestionsDisposeSpy).toHaveBeenCalled(); expect(removeEventListenerSpy).toHaveBeenCalledWith('keyup', manager.keyPressListener); removeEventListenerSpy.mockRestore(); }); }); describe('Keyboard shortcuts', () => { it('Should open command palette on Ctrl+P', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { expect(manager.isOpened.getValue()).toBe(false); manager.keyPressListener({ key: 'p', ctrlKey: true }); expect(manager.isOpened.getValue()).toBe(true); }); }); it('Should open command palette on Ctrl+P (uppercase P)', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { expect(manager.isOpened.getValue()).toBe(false); manager.keyPressListener({ key: 'P', ctrlKey: true }); expect(manager.isOpened.getValue()).toBe(true); }); }); it('Should clear suggestions when opening with Ctrl+P', () => { const suggestions = [createMockSuggestion('test', 1)]; const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.currentSuggestions.setValue(suggestions); manager.keyPressListener({ key: 'p', ctrlKey: true }); expect(manager.currentSuggestions.getValue()).toEqual([]); }); }); it('Should not open command palette on P without Ctrl', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: 'p', ctrlKey: false }); expect(manager.isOpened.getValue()).toBe(false); }); }); it('Should not open command palette on Ctrl without P', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: 'a', ctrlKey: true }); expect(manager.isOpened.getValue()).toBe(false); }); }); it('Should close command palette on Escape', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.isOpened.setValue(true); expect(manager.isOpened.getValue()).toBe(true); manager.keyPressListener({ key: 'Escape' }); expect(manager.isOpened.getValue()).toBe(false); }); }); it('Should not close command palette for other keys', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.isOpened.setValue(true); manager.keyPressListener({ key: 'Enter' }); expect(manager.isOpened.getValue()).toBe(true); manager.keyPressListener({ key: 'ArrowDown' }); expect(manager.isOpened.getValue()).toBe(true); }); }); it('Should handle undefined key gracefully', () => { const providers = []; using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: undefined, ctrlKey: true }); expect(manager.isOpened.getValue()).toBe(false); }); }); }); describe('getSuggestion (debounced search)', () => { it('Should load suggestions after debounce delay', async () => { const suggestions = [createMockSuggestion('test', 1)]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); expect(provider).not.toHaveBeenCalled(); expect(manager.isLoading.getValue()).toBe(false); await vi.advanceTimersByTimeAsync(250); expect(provider).toHaveBeenCalledWith({ injector, term: 'test' }); expect(manager.currentSuggestions.getValue()).toHaveLength(1); }); }); }); it('Should set isLoading while fetching', async () => { let resolveProvider; const providerPromise = new Promise((resolve) => { resolveProvider = resolve; }); const provider = vi.fn().mockReturnValue(providerPromise); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.isLoading.getValue()).toBe(true); resolveProvider([]); await vi.advanceTimersByTimeAsync(0); expect(manager.isLoading.getValue()).toBe(false); }); }); }); it('Should debounce multiple rapid calls', async () => { const suggestions = [createMockSuggestion('test', 1)]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'a' }); await vi.advanceTimersByTimeAsync(100); void manager.getSuggestion({ injector, term: 'ab' }); await vi.advanceTimersByTimeAsync(100); void manager.getSuggestion({ injector, term: 'abc' }); await vi.advanceTimersByTimeAsync(250); expect(provider).toHaveBeenCalledTimes(1); expect(provider).toHaveBeenCalledWith({ injector, term: 'abc' }); }); }); }); it('Should not fetch again if term is unchanged', async () => { const suggestions = [createMockSuggestion('test', 1)]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(provider).toHaveBeenCalledTimes(1); void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(provider).toHaveBeenCalledTimes(1); }); }); }); it('Should reset selectedIndex when fetching new suggestions', async () => { const suggestions = [createMockSuggestion('test', 1)]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { manager.selectedIndex.setValue(5); void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.selectedIndex.getValue()).toBe(0); }); }); }); it('Should clear suggestions when starting new search', async () => { const suggestions = [createMockSuggestion('test', 1)]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.currentSuggestions.getValue()).toHaveLength(1); void manager.getSuggestion({ injector, term: 'test2' }); await vi.advanceTimersByTimeAsync(250); expect(manager.currentSuggestions.getValue()).toHaveLength(1); }); }); }); it('Should set isLoading to false even when error occurs', async () => { const provider = vi.fn().mockImplementation(async () => { throw new Error('Network error'); }); // Suppress expected unhandled rejection from debounced async error const errorHandler = (reason) => { if (reason?.message === 'Network error') { return; } throw reason; }; process.on('unhandledRejection', errorHandler); try { await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); // Wait for promise rejection to be handled await vi.advanceTimersByTimeAsync(0); expect(manager.isLoading.getValue()).toBe(false); }); }); } finally { process.removeListener('unhandledRejection', errorHandler); } }); }); describe('Multiple command providers', () => { it('Should call all providers when getting suggestions', async () => { const provider1 = createCommandProvider([createMockSuggestion('cmd1', 1)]); const provider2 = createCommandProvider([createMockSuggestion('cmd2', 2)]); const provider3 = createCommandProvider([createMockSuggestion('cmd3', 3)]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider1, provider2, provider3]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(provider1).toHaveBeenCalledWith({ injector, term: 'test' }); expect(provider2).toHaveBeenCalledWith({ injector, term: 'test' }); expect(provider3).toHaveBeenCalledWith({ injector, term: 'test' }); }); }); }); it('Should aggregate suggestions from all providers', async () => { const provider1 = createCommandProvider([createMockSuggestion('cmd1', 1)]); const provider2 = createCommandProvider([createMockSuggestion('cmd2', 2), createMockSuggestion('cmd3', 3)]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider1, provider2]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.currentSuggestions.getValue()).toHaveLength(3); }); }); }); it('Should sort suggestions by score', async () => { const provider1 = createCommandProvider([createMockSuggestion('low', 1)]); const provider2 = createCommandProvider([createMockSuggestion('high', 10)]); const provider3 = createCommandProvider([createMockSuggestion('medium', 5)]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider1, provider2, provider3]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); const suggestions = manager.currentSuggestions.getValue(); expect(suggestions[0].score).toBe(1); expect(suggestions[1].score).toBe(5); expect(suggestions[2].score).toBe(10); }); }); }); it('Should handle providers returning empty arrays', async () => { const provider1 = createCommandProvider([createMockSuggestion('cmd1', 1)]); const provider2 = createCommandProvider([]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider1, provider2]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.currentSuggestions.getValue()).toHaveLength(1); }); }); }); it('Should work with no providers', async () => { await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); expect(manager.currentSuggestions.getValue()).toHaveLength(0); expect(manager.isLoading.getValue()).toBe(false); }); }); }); it('Should handle provider errors gracefully', async () => { const provider1 = createCommandProvider([createMockSuggestion('cmd1', 1)]); const provider2 = vi.fn().mockImplementation(async () => { throw new Error('Provider failed'); }); // Suppress expected unhandled rejection from debounced async error const errorHandler = (reason) => { if (reason?.message === 'Provider failed') { return; } throw reason; }; process.on('unhandledRejection', errorHandler); try { await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider1, provider2]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); // Wait for promise rejection to be handled await vi.advanceTimersByTimeAsync(0); expect(manager.isLoading.getValue()).toBe(false); }); }); } finally { process.removeListener('unhandledRejection', errorHandler); } }); }); describe('selectSuggestion', () => { it('Should call onSelected callback with injector', async () => { const suggestion = createMockSuggestion('cmd', 1); const provider = createCommandProvider([suggestion]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); manager.selectSuggestion(injector, 0); expect(suggestion.onSelected).toHaveBeenCalledWith({ injector }); }); }); }); it('Should use current selectedIndex when no index provided', async () => { const suggestions = [ createMockSuggestion('cmd1', 1), createMockSuggestion('cmd2', 2), createMockSuggestion('cmd3', 3), ]; const provider = createCommandProvider(suggestions); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); manager.selectedIndex.setValue(2); manager.selectSuggestion(injector); expect(suggestions[2].onSelected).toHaveBeenCalledWith({ injector }); }); }); }); it('Should close the command palette after selection', async () => { const suggestion = createMockSuggestion('cmd', 1); const provider = createCommandProvider([suggestion]); await usingAsync(createInjector(), async (injector) => { await usingAsync(new CommandPaletteManager([provider]), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }); await vi.advanceTimersByTimeAsync(250); manager.isOpened.setValue(true); expect(manager.isOpened.getValue()).toBe(true); manager.selectSuggestion(injector, 0); expect(manager.isOpened.getValue()).toBe(false); }); }); }); }); }); //# sourceMappingURL=command-palette-manager.spec.js.map