import { fetchMock } from '../../../test/fetch-mock.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 = [ { label: 'artist:assasinmonkey (1)', value: 'artist:assasinmonkey' }, { label: 'artist:hydrusbeta (1)', value: 'artist:hydrusbeta' }, { label: 'artist:the sexy assistant (1)', value: 'artist:the sexy assistant' }, { label: 'artist:devinian (1)', value: 'artist:devinian' }, { 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(); mockedAutocompleteBuffer = await fs.promises .readFile(path.join(__dirname, 'autocomplete-compiled-v2.bin')) .then(fileBuffer => fileBuffer.buffer); }); afterAll(() => { fetchMock.disableMocks(); }); beforeEach(() => { purgeSuggestionsCache(); 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 loop around when selecting next on last and previous on first', () => { [popup, input] = mockBaseSuggestionsPopup(true); const firstItem = document.querySelector('.autocomplete__item:first-child'); const lastItem = document.querySelector('.autocomplete__item:last-child'); if (lastItem) { fireEvent.mouseOver(lastItem); fireEvent.mouseMove(lastItem); } expect(lastItem).toHaveClass(selectedItemClassName); popup.selectNext(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); popup.selectNext(); expect(firstItem).toHaveClass(selectedItemClassName); popup.selectPrevious(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); popup.selectPrevious(); expect(lastItem).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]); }); it('should not emit selection on items without value', () => { [popup, input] = mockBaseSuggestionsPopup(); popup.renderSuggestions([{ label: 'Option without value', value: '' }]); const itemSelectionHandler = vi.fn(); popup.onItemSelected(itemSelectionHandler); const firstItem = document.querySelector('.autocomplete__item:first-child')!; if (firstItem) { fireEvent.click(firstItem); } expect(itemSelectionHandler).not.toBeCalled(); }); }); describe('fetchSuggestions', () => { it('should only call fetch once per single term', () => { fetchSuggestions(mockedSuggestionsEndpoint, 'art'); fetchSuggestions(mockedSuggestionsEndpoint, 'art'); expect(fetch).toHaveBeenCalledTimes(1); }); it('should be case-insensitive to terms and trim spaces', () => { fetchSuggestions(mockedSuggestionsEndpoint, 'art'); fetchSuggestions(mockedSuggestionsEndpoint, 'Art'); fetchSuggestions(mockedSuggestionsEndpoint, ' ART '); expect(fetch).toHaveBeenCalledTimes(1); }); it('should return the same suggestions from cache', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); const firstSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); const secondSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); expect(firstSuggestions).toBe(secondSuggestions); }); it('should parse and return array of suggestions', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); expect(resolvedSuggestions).toBeInstanceOf(Array); expect(resolvedSuggestions.length).toBe(mockedSuggestionsResponse.length); expect(resolvedSuggestions).toEqual(mockedSuggestionsResponse); }); it('should return empty array on server error', async () => { fetchMock.mockResolvedValueOnce(new Response('', { status: 500 })); const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'unknown tag'); expect(resolvedSuggestions).toBeInstanceOf(Array); expect(resolvedSuggestions.length).toBe(0); }); it('should return empty array on invalid response format', async () => { fetchMock.mockResolvedValueOnce(new Response('invalid non-JSON response', { status: 200 })); const resolvedSuggestions = await fetchSuggestions(mockedSuggestionsEndpoint, 'invalid response'); expect(resolvedSuggestions).toBeInstanceOf(Array); expect(resolvedSuggestions.length).toBe(0); }); }); describe('purgeSuggestionsCache', () => { it('should clear cached responses', async () => { fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(mockedSuggestionsResponse), { status: 200 })); const firstResult = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); purgeSuggestionsCache(); const resultAfterPurge = await fetchSuggestions(mockedSuggestionsEndpoint, 'art'); expect(fetch).toBeCalledTimes(2); expect(firstResult).not.toBe(resultAfterPurge); }); }); 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}`; fetchLocalAutocomplete(); expect(fetch).toBeCalledWith(expectedEndpoint, { credentials: 'omit', cache: 'force-cache' }); }); it('should return auto-completer instance', async () => { fetchMock.mockResolvedValue(new Response(mockedAutocompleteBuffer, { status: 200 })); const autocomplete = await fetchLocalAutocomplete(); expect(autocomplete).toBeInstanceOf(LocalAutocompleter); }); it('should throw generic server error on failing response', async () => { fetchMock.mockResolvedValue(new Response('error', { status: 500 })); expect(() => fetchLocalAutocomplete()).rejects.toThrowError('Received error from server'); }); }); });