diff --git a/assets/js/utils/__tests__/suggestions.spec.ts b/assets/js/utils/__tests__/suggestions.spec.ts index f7eaa489..b65ca94f 100644 --- a/assets/js/utils/__tests__/suggestions.spec.ts +++ b/assets/js/utils/__tests__/suggestions.spec.ts @@ -1,8 +1,16 @@ import { fetchMock } from '../../../test/fetch-mock.ts'; -import { fetchLocalAutocomplete, fetchSuggestions, purgeSuggestionsCache } from '../suggestions.ts'; +import { + fetchLocalAutocomplete, + fetchSuggestions, + purgeSuggestionsCache, + SuggestionsPopup, + TermSuggestion, +} from '../suggestions.ts'; import fs from 'fs'; import path from 'path'; import { LocalAutocompleter } from '../local-autocompleter.ts'; +import { afterEach } from 'vitest'; +import { fireEvent } from '@testing-library/dom'; const mockedSuggestionsEndpoint = '/endpoint?term='; const mockedSuggestionsResponse = [ @@ -13,8 +21,26 @@ const mockedSuggestionsResponse = [ { label: 'artist:moe (1)', value: 'artist:moe' }, ]; +function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { + const input = document.createElement('input'); + const popup = new SuggestionsPopup(); + + document.body.append(input); + popup.showForField(input); + + if (includeMockedSuggestions) { + popup.renderSuggestions(mockedSuggestionsResponse); + } + + return [popup, input]; +} + +const selectedItemClassName = 'autocomplete__item--selected'; + describe('Suggestions', () => { let mockedAutocompleteBuffer: ArrayBuffer; + let popup: SuggestionsPopup | undefined; + let input: HTMLInputElement | undefined; beforeAll(async () => { fetchMock.enableMocks(); @@ -33,6 +59,136 @@ describe('Suggestions', () => { fetchMock.resetMocks(); }); + afterEach(() => { + if (input) { + input.remove(); + input = undefined; + } + + if (popup) { + popup.hide(); + popup = undefined; + } + }); + + describe('SuggestionsPopup', () => { + it('should create the popup container', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(true); + }); + + it('should be removed when hidden', () => { + [popup, input] = mockBaseSuggestionsPopup(); + + popup.hide(); + + expect(document.querySelector('.autocomplete')).not.toBeInstanceOf(HTMLElement); + expect(popup.isActive).toBe(false); + }); + + it('should render suggestions', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(document.querySelectorAll('.autocomplete__item').length).toBe(mockedSuggestionsResponse.length); + }); + + it('should initially select first element when selectNext called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectNext(); + + expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); + }); + + it('should initially select last element when selectPrevious called', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + popup.selectPrevious(); + + expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); + }); + + it('should select and de-select items when hovering items over', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const firstItem = document.querySelector('.autocomplete__item:first-child'); + const lastItem = document.querySelector('.autocomplete__item:last-child'); + + if (firstItem) { + fireEvent.mouseOver(firstItem); + fireEvent.mouseMove(firstItem); + } + + expect(firstItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOver(lastItem); + fireEvent.mouseMove(lastItem); + } + + expect(firstItem).not.toHaveClass(selectedItemClassName); + expect(lastItem).toHaveClass(selectedItemClassName); + + if (lastItem) { + fireEvent.mouseOut(lastItem); + } + + expect(lastItem).not.toHaveClass(selectedItemClassName); + }); + + it('should allow switching between mouse and selection', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + const secondItem = document.querySelector('.autocomplete__item:nth-child(2)'); + const thirdItem = document.querySelector('.autocomplete__item:nth-child(3)'); + + if (secondItem) { + fireEvent.mouseOver(secondItem); + fireEvent.mouseMove(secondItem); + } + + expect(secondItem).toHaveClass(selectedItemClassName); + + popup.selectNext(); + + expect(secondItem).not.toHaveClass(selectedItemClassName); + expect(thirdItem).toHaveClass(selectedItemClassName); + }); + + it('should return selected item value', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + expect(popup.selectedTerm).toBe(null); + + popup.selectNext(); + + expect(popup.selectedTerm).toBe(mockedSuggestionsResponse[0].value); + }); + + it('should emit an event when item was clicked with mouse', () => { + [popup, input] = mockBaseSuggestionsPopup(true); + + let clickEvent: CustomEvent | undefined; + + const itemSelectedHandler = vi.fn((event: CustomEvent) => { + clickEvent = event; + }); + + popup.onItemSelected(itemSelectedHandler); + + const firstItem = document.querySelector('.autocomplete__item'); + + if (firstItem) { + fireEvent.click(firstItem); + } + + expect(itemSelectedHandler).toBeCalledTimes(1); + expect(clickEvent?.detail).toEqual(mockedSuggestionsResponse[0]); + }); + }); + describe('fetchSuggestions', () => { it('should only call fetch once per single term', () => { fetchSuggestions(mockedSuggestionsEndpoint, 'art'); @@ -102,6 +258,8 @@ describe('Suggestions', () => { describe('fetchLocalAutocomplete', () => { it('should request binary with date-related cache key', () => { + fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); + const now = new Date(); const cacheKey = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}`; const expectedEndpoint = `/autocomplete/compiled?vsn=2&key=${cacheKey}`;