import { SuggestionsPopup, TagSuggestion, TagSuggestionParams, Suggestions, HistorySuggestion, ItemSelectedEvent, } from '../suggestions.ts'; import { afterEach } from 'vitest'; import { fireEvent } from '@testing-library/dom'; import { assertNotNull } from '../assert.ts'; const mockedSuggestions: Suggestions = { history: ['foo bar', 'bar baz', 'baz qux'].map(content => new HistorySuggestion(content, 0)), tags: [ { images: 10, canonical: 'artist:assasinmonkey' }, { images: 10, canonical: 'artist:hydrusbeta' }, { images: 10, canonical: 'artist:the sexy assistant' }, { images: 10, canonical: 'artist:devinian' }, { images: 10, canonical: 'artist:moe' }, ].map(tags => new TagSuggestion({ ...tags, matchLength: 0 })), }; function mockBaseSuggestionsPopup(includeMockedSuggestions: boolean = false): [SuggestionsPopup, HTMLInputElement] { const input = document.createElement('input'); const popup = new SuggestionsPopup(); document.body.append(input); popup.showForElement(input); if (includeMockedSuggestions) { popup.setSuggestions(mockedSuggestions); } return [popup, input]; } const selectedItemClassName = 'autocomplete__item--selected'; describe('Suggestions', () => { let popup: SuggestionsPopup | undefined; let input: HTMLInputElement | undefined; afterEach(() => { if (input) { input.remove(); input = undefined; } if (popup) { popup.hide(); popup.setSuggestions({ history: [], tags: [] }); popup = undefined; } }); describe('SuggestionsPopup', () => { it('should create the popup container', () => { [popup, input] = mockBaseSuggestionsPopup(); expect(document.querySelector('.autocomplete')).toBeInstanceOf(HTMLElement); expect(popup.isHidden).toBe(false); }); it('should render suggestions', () => { [popup, input] = mockBaseSuggestionsPopup(true); expect(document.querySelectorAll('.autocomplete__item').length).toBe( mockedSuggestions.history.length + mockedSuggestions.tags.length, ); }); it('should initially select first element when selectDown is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); popup.selectDown(); expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); }); it('should initially select last element when selectUp is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); popup.selectUp(); expect(document.querySelector('.autocomplete__item:last-child')).toHaveClass(selectedItemClassName); }); it('should jump to the next lower block when selectCtrlDown is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); popup.selectCtrlDown(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags[0]); expect(document.querySelector('.autocomplete__item__tag')).toHaveClass(selectedItemClassName); popup.selectCtrlDown(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); // Should loop around popup.selectCtrlDown(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); }); it('should jump to the next upper block when selectCtrlUp is called', () => { [popup, input] = mockBaseSuggestionsPopup(true); popup.selectCtrlUp(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); popup.selectCtrlUp(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.history.at(-1)); expect( document.querySelector(`.autocomplete__item__history:nth-child(${mockedSuggestions.history.length})`), ).toHaveClass(selectedItemClassName); popup.selectCtrlUp(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); expect(document.querySelector('.autocomplete__item:first-child')).toHaveClass(selectedItemClassName); // Should loop around popup.selectCtrlUp(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.tags.at(-1)); expect(document.querySelector('.autocomplete__item__tag:last-child')).toHaveClass(selectedItemClassName); }); it('should do nothing on selection changes when empty', () => { [popup, input] = mockBaseSuggestionsPopup(); popup.selectDown(); popup.selectUp(); popup.selectCtrlDown(); popup.selectCtrlUp(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); }); it('should loop around when selecting next on last and previous on first', () => { [popup, input] = mockBaseSuggestionsPopup(true); const firstItem = assertNotNull(document.querySelector('.autocomplete__item:first-child')); const lastItem = assertNotNull(document.querySelector('.autocomplete__item:last-child')); popup.selectUp(); expect(lastItem).toHaveClass(selectedItemClassName); popup.selectDown(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); popup.selectDown(); expect(firstItem).toHaveClass(selectedItemClassName); popup.selectUp(); expect(document.querySelector(`.${selectedItemClassName}`)).toBeNull(); popup.selectUp(); expect(lastItem).toHaveClass(selectedItemClassName); }); it('should return selected item value', () => { [popup, input] = mockBaseSuggestionsPopup(true); expect(popup.selectedSuggestion).toBe(null); popup.selectDown(); expect(popup.selectedSuggestion).toBe(mockedSuggestions.history[0]); }); it('should emit an event when an item was clicked with a mouse', () => { [popup, input] = mockBaseSuggestionsPopup(true); const itemSelectedHandler = vi.fn<(event: ItemSelectedEvent) => void>(); popup.onItemSelected(itemSelectedHandler); const firstItem = assertNotNull(document.querySelector('.autocomplete__item')); fireEvent.click(firstItem); expect(itemSelectedHandler).toBeCalledTimes(1); expect(itemSelectedHandler).toBeCalledWith({ ctrlKey: false, shiftKey: false, suggestion: mockedSuggestions.history[0], }); }); }); describe('HistorySuggestion', () => { it('should render the suggestion', () => { expectHistoryRender('foo bar').toMatchInlineSnapshot(` { "label": " foo bar", "value": "foo bar", } `); }); }); describe('TagSuggestion', () => { it('should format suggested tags as tag name and the count', () => { // The snapshots in this test contain a "narrow no-break space" /* eslint-disable no-irregular-whitespace */ expectTagRender({ canonical: 'safe', images: 10 }).toMatchInlineSnapshot(` { "label": " safe 10", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 10_000 }).toMatchInlineSnapshot(` { "label": " safe 10 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 100_000 }).toMatchInlineSnapshot(` { "label": " safe 100 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 1000_000 }).toMatchInlineSnapshot(` { "label": " safe 1 000 000", "value": "safe", } `); expectTagRender({ canonical: 'safe', images: 10_000_000 }).toMatchInlineSnapshot(` { "label": " safe 10 000 000", "value": "safe", } `); /* eslint-enable no-irregular-whitespace */ }); it('should display alias -> canonical for aliased tags', () => { expectTagRender({ images: 10, canonical: 'safe', alias: 'rating:safe' }).toMatchInlineSnapshot( ` { "label": " rating:safe → safe 10", "value": "safe", } `, ); }); }); }); function expectHistoryRender(content: string) { const suggestion = new HistorySuggestion(content, 0); const label = suggestion .render() .map(el => el.textContent) .join(''); const value = suggestion.value(); return expect({ label, value }); } function expectTagRender(params: Omit<TagSuggestionParams, 'matchLength'>) { const suggestion = new TagSuggestion({ ...params, matchLength: 0 }); const label = suggestion .render() .map(el => el.textContent) .join(''); const value = suggestion.value(); return expect({ label, value }); }