UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

486 lines (376 loc) 18.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' import type { CommandPaletteSuggestionResult, CommandProvider } from './command-provider.js' const createMockSuggestion = (name: string, score: number): CommandPaletteSuggestionResult => ({ element: { tagName: 'div', textContent: name } as unknown as JSX.Element, score, onSelected: vi.fn(), }) const createCommandProvider = (suggestions: CommandPaletteSuggestionResult[]): CommandProvider => { 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: CommandProvider[] = [] 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: CommandProvider[] = [] 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: CommandProvider[] = [] 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: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { expect(manager.isOpened.getValue()).toBe(false) manager.keyPressListener({ key: 'p', ctrlKey: true } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) }) }) it('Should open command palette on Ctrl+P (uppercase P)', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { expect(manager.isOpened.getValue()).toBe(false) manager.keyPressListener({ key: 'P', ctrlKey: true } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) }) }) it('Should clear suggestions when opening with Ctrl+P', () => { const suggestions = [createMockSuggestion('test', 1)] const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.currentSuggestions.setValue(suggestions) manager.keyPressListener({ key: 'p', ctrlKey: true } as KeyboardEvent) expect(manager.currentSuggestions.getValue()).toEqual([]) }) }) it('Should not open command palette on P without Ctrl', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: 'p', ctrlKey: false } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(false) }) }) it('Should not open command palette on Ctrl without P', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: 'a', ctrlKey: true } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(false) }) }) it('Should close command palette on Escape', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.isOpened.setValue(true) expect(manager.isOpened.getValue()).toBe(true) manager.keyPressListener({ key: 'Escape' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(false) }) }) it('Should not close command palette for other keys', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.isOpened.setValue(true) manager.keyPressListener({ key: 'Enter' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) manager.keyPressListener({ key: 'ArrowDown' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) }) }) it('Should handle undefined key gracefully', () => { const providers: CommandProvider[] = [] using(new CommandPaletteManager(providers), (manager) => { manager.keyPressListener({ key: undefined, ctrlKey: true } as unknown as KeyboardEvent) 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: (value: CommandPaletteSuggestionResult[]) => void const providerPromise = new Promise<CommandPaletteSuggestionResult[]>((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: unknown) => { if ((reason as Error)?.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: unknown) => { if ((reason as Error)?.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) }) }) }) }) })