@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
318 lines • 18.1 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { using, usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SuggestManager } from './suggest-manager.js';
const createTestEntries = () => [
{ id: 1, name: 'alpha' },
{ id: 2, name: 'beta' },
{ id: 3, name: 'gamma' },
];
const createSuggestionResult = (entry) => ({
element: { tagName: 'div', textContent: entry.name },
score: entry.id,
});
describe('SuggestManager', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('Construction and disposal', () => {
it('Should be constructed with getEntries and getSuggestionEntry functions', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (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 and click listeners on construction', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), () => {
expect(addEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function), true);
expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true);
});
addEventListenerSpy.mockRestore();
});
it('Should dispose all observables and remove event listeners', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
const manager = new SuggestManager(getEntries, getSuggestionEntry);
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, true);
expect(removeEventListenerSpy).toHaveBeenCalledWith('click', manager.clickOutsideListener, true);
removeEventListenerSpy.mockRestore();
});
});
describe('Keyboard listener', () => {
it('Should close suggestions when Escape is pressed', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (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 suggestions for other keys', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (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);
});
});
});
describe('Click-outside listener', () => {
it('Should close suggestions when clicking outside the element', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => {
const element = document.createElement('div');
element.setAttribute('data-testid', 'suggest');
document.body.appendChild(element);
manager.element = element;
manager.isOpened.setValue(true);
const outsideElement = document.createElement('span');
document.body.appendChild(outsideElement);
manager.clickOutsideListener({ target: outsideElement });
expect(manager.isOpened.getValue()).toBe(false);
document.body.removeChild(element);
document.body.removeChild(outsideElement);
});
});
it('Should not close suggestions when clicking inside the element', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => {
const element = document.createElement('div');
const childElement = document.createElement('span');
element.appendChild(childElement);
document.body.appendChild(element);
manager.element = element;
manager.isOpened.setValue(true);
manager.clickOutsideListener({ target: childElement });
expect(manager.isOpened.getValue()).toBe(true);
document.body.removeChild(element);
});
});
it('Should not close when element is not set', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => {
manager.isOpened.setValue(true);
const outsideElement = document.createElement('span');
manager.clickOutsideListener({ target: outsideElement });
expect(manager.isOpened.getValue()).toBe(true);
});
});
it('Should not close when already closed', () => {
const getEntries = vi.fn().mockResolvedValue([]);
const getSuggestionEntry = vi.fn();
using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => {
const element = document.createElement('div');
document.body.appendChild(element);
manager.element = element;
manager.isOpened.setValue(false);
const outsideElement = document.createElement('span');
document.body.appendChild(outsideElement);
manager.clickOutsideListener({ target: outsideElement });
expect(manager.isOpened.getValue()).toBe(false);
document.body.removeChild(element);
document.body.removeChild(outsideElement);
});
});
});
describe('getSuggestion (debounced search)', () => {
it('Should load suggestions after debounce delay', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
expect(getEntries).not.toHaveBeenCalled();
expect(manager.isLoading.getValue()).toBe(false);
await vi.advanceTimersByTimeAsync(250);
expect(getEntries).toHaveBeenCalledWith('test');
expect(manager.isOpened.getValue()).toBe(true);
expect(manager.currentSuggestions.getValue()).toHaveLength(3);
});
});
});
it('Should set isLoading while fetching', async () => {
const testEntries = createTestEntries();
let resolveEntries;
const entriesPromise = new Promise((resolve) => {
resolveEntries = resolve;
});
const getEntries = vi.fn().mockReturnValue(entriesPromise);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
expect(manager.isLoading.getValue()).toBe(true);
resolveEntries(testEntries);
await vi.advanceTimersByTimeAsync(0);
expect(manager.isLoading.getValue()).toBe(false);
});
});
});
it('Should debounce multiple rapid calls', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), 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(getEntries).toHaveBeenCalledTimes(1);
expect(getEntries).toHaveBeenCalledWith('abc');
});
});
});
it('Should not fetch again if term is unchanged', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
expect(getEntries).toHaveBeenCalledTimes(1);
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
expect(getEntries).toHaveBeenCalledTimes(1);
});
});
});
it('Should map entries to suggestions with getSuggestionEntry', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
expect(getSuggestionEntry).toHaveBeenCalledTimes(3);
expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[0]);
expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[1]);
expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[2]);
const suggestions = manager.currentSuggestions.getValue();
expect(suggestions[0].entry).toBe(testEntries[0]);
expect(suggestions[0].suggestion.score).toBe(1);
});
});
});
it('Should preserve selected index when suggestion exists in new results', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
manager.selectedIndex.setValue(1);
const newEntries = [testEntries[1], testEntries[2]];
getEntries.mockResolvedValue(newEntries);
getSuggestionEntry.mockImplementation(createSuggestionResult);
void manager.getSuggestion({ injector, term: 'test2' });
await vi.advanceTimersByTimeAsync(250);
expect(manager.selectedIndex.getValue()).toBe(0);
});
});
});
it('Should reset selected index to 0 when selection not found in new results', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
manager.selectedIndex.setValue(2);
const newEntries = [{ id: 4, name: 'delta' }];
getEntries.mockResolvedValue(newEntries);
void manager.getSuggestion({ injector, term: 'test2' });
await vi.advanceTimersByTimeAsync(250);
expect(manager.selectedIndex.getValue()).toBe(0);
});
});
});
});
describe('selectSuggestion', () => {
it('Should emit onSelectSuggestion event with selected entry', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
const onSelect = vi.fn();
manager.subscribe('onSelectSuggestion', onSelect);
manager.selectSuggestion(1);
expect(onSelect).toHaveBeenCalledWith(testEntries[1]);
expect(manager.isOpened.getValue()).toBe(false);
});
});
});
it('Should use current selectedIndex when no index provided', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
manager.selectedIndex.setValue(2);
const onSelect = vi.fn();
manager.subscribe('onSelectSuggestion', onSelect);
manager.selectSuggestion();
expect(onSelect).toHaveBeenCalledWith(testEntries[2]);
});
});
});
it('Should close the suggestion list after selection', async () => {
const testEntries = createTestEntries();
const getEntries = vi.fn().mockResolvedValue(testEntries);
const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult);
await usingAsync(createInjector(), async (injector) => {
await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => {
void manager.getSuggestion({ injector, term: 'test' });
await vi.advanceTimersByTimeAsync(250);
expect(manager.isOpened.getValue()).toBe(true);
manager.selectSuggestion(0);
expect(manager.isOpened.getValue()).toBe(false);
});
});
});
});
});
//# sourceMappingURL=suggest-manager.spec.js.map