philomena/assets/js/utils/__tests__/suggestions.spec.ts

334 lines
11 KiB
TypeScript

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<TermSuggestion> | undefined;
const itemSelectedHandler = vi.fn((event: CustomEvent<TermSuggestion>) => {
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');
});
});
});