diff --git a/assets/js/autocomplete/__tests__/context.ts b/assets/js/autocomplete/__tests__/context.ts new file mode 100644 index 00000000..fabfab23 --- /dev/null +++ b/assets/js/autocomplete/__tests__/context.ts @@ -0,0 +1,269 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { fetchMock } from '../../../test/fetch-mock'; +import { listenAutocomplete } from '..'; +import { fireEvent } from '@testing-library/dom'; +import { assertNotNull } from '../../utils/assert'; +import { TextInputElement } from '../input'; +import store from '../../utils/store'; +import { GetTagSuggestionsResponse } from 'autocomplete/client'; + +/** + * A reusable test environment for autocompletion tests. Note that it does no + * attempt to provide environment cleanup functionality. Yes, if you use this + * in several tests in one file, then tests will conflict with each other. + * + * The main problem of implementing the cleanup here is that autocomplete code + * adds event listeners to the `document` object. Some of them could be moved + * to the `` element, but events such as `'storage'` are only available + * on the document object. + * + * Unfortunately, there isn't a good easy way to reload the DOM completely in + * `jsdom`, so it's expected that you define a single test per file so that + * `vitest` runs every test in an isolated process, where no cleanup is needed. + * + * I wish `vitest` actually did that by default, because cleanup logic and test + * in-process test isolation is just boilerplate that we could avoid at this + * scale at least. + */ +export class TestContext { + private input: TextInputElement; + private popup: HTMLElement; + readonly fakeAutocompleteResponse: Response; + + constructor(fakeAutocompleteResponse: Response) { + this.fakeAutocompleteResponse = fakeAutocompleteResponse; + + vi.useFakeTimers().setSystemTime(0); + fetchMock.enableMocks(); + + // Our mock backend implementation. + fetchMock.mockResponse(request => { + if (request.url.includes('/autocomplete/compiled')) { + return this.fakeAutocompleteResponse; + } + + const url = new URL(request.url); + if (url.searchParams.get('term')?.toLowerCase() !== 'mar') { + const suggestions: GetTagSuggestionsResponse = { suggestions: [] }; + return JSON.stringify(suggestions); + } + + const suggestions: GetTagSuggestionsResponse = { + suggestions: [ + { + alias: 'marvelous', + canonical: 'beautiful', + images: 30, + }, + { + canonical: 'mare', + images: 20, + }, + { + canonical: 'market', + images: 10, + }, + ], + }; + + return JSON.stringify(suggestions); + }); + + store.set('enable_search_ac', true); + + document.body.innerHTML = ` +
+ +
+ `; + + listenAutocomplete(); + + this.input = assertNotNull(document.querySelector('.test-input')); + this.popup = assertNotNull(document.querySelector('.autocomplete')); + + expect(fetch).not.toBeCalled(); + } + + async submitForm(input?: string) { + if (input) { + await this.setInput(input); + } + + this.input.form!.submit(); + + await this.setInput(''); + } + + async focusInput() { + this.input.focus(); + await vi.runAllTimersAsync(); + } + + /** + * Sets the input to `value`. Allows for a special `<>` syntax. These characters + * are removed from the input. Their position is used to set the selection. + * + * - `<` denotes the `selectionStart` + * - `>` denotes the `selectionEnd`. + */ + async setInput(value: string) { + if (document.activeElement !== this.input) { + await this.focusInput(); + } + + const valueChars = [...value]; + + const selectionStart = valueChars.indexOf('<'); + if (selectionStart >= 0) { + valueChars.splice(selectionStart, 1); + } + + const selectionEnd = valueChars.indexOf('>'); + if (selectionEnd >= 0) { + valueChars.splice(selectionEnd, 1); + } + + this.input.value = valueChars.join(''); + if (selectionStart >= 0) { + this.input.selectionStart = selectionStart; + } + if (selectionEnd >= 0) { + this.input.selectionEnd = selectionEnd; + } + + fireEvent.input(this.input, { target: { value: this.input.value } }); + + await vi.runAllTimersAsync(); + } + + async keyDown(code: string, params?: { ctrlKey?: boolean }) { + fireEvent.keyDown(this.input, { code, ...(params ?? {}) }); + await vi.runAllTimersAsync(); + } + + expectRequests() { + const snapshot = vi.mocked(fetch).mock.calls.map(([input]) => { + const request = input as unknown as Request; + const meta: Record = {}; + + const url = new URL(request.url); + + const methodAndUrl = `${request.method} ${url}`; + + if (request.credentials !== 'same-origin') { + meta.credentials = request.credentials; + } + + if (request.cache !== 'default') { + meta.cache = request.cache; + } + + if (Object.getOwnPropertyNames(meta).length === 0) { + return methodAndUrl; + } + + return { + dest: methodAndUrl, + meta, + }; + }); + + return expect(snapshot); + } + + /** + * The snapshot of the UI uses some special syntax like `<>` to denote the + * selection start (`<`) and end (`>`), as well as some markers for the + * currently selected item and history suggestions. + */ + expectUi() { + const input = this.inputSnapshot(); + const suggestions = this.suggestionsSnapshot(); + + return expect({ input, suggestions }); + } + + suggestionsSnapshot() { + const { popup } = this; + + if (popup.classList.contains('hidden')) { + return []; + } + + return [...popup.children].map(el => { + if (el.tagName === 'HR') { + return '-----------'; + } + + let content = el.textContent!.trim(); + + if (el.classList.contains('autocomplete__item__history')) { + content = `(history) ${content}`; + } + + if (el.classList.contains('autocomplete__item--selected')) { + return `👉 ${content}`; + } + return content; + }); + } + + inputSnapshot() { + const { input } = this; + + const value = [...input.value]; + + if (input.selectionStart) { + value.splice(input.selectionStart, 0, '<'); + } + + if (input.selectionEnd) { + const shift = input.selectionStart && input.selectionStart <= input.selectionEnd ? 1 : 0; + + value.splice(input.selectionEnd + shift, 0, '>'); + } + + return value.join(''); + } +} + +export async function init(): Promise { + const fakeAutocompleteBuffer = await fs.promises + .readFile(path.join(__dirname, '../../utils/__tests__/autocomplete-compiled-v2.bin')) + .then(({ buffer }) => new Response(buffer)); + + const ctx = new TestContext(fakeAutocompleteBuffer); + + expect(fetch).not.toHaveBeenCalled(); + + // Initialize the lazy autocomplete index cache + await ctx.focusInput(); + + ctx.expectRequests().toMatchInlineSnapshot(` + [ + { + "dest": "GET http://localhost:3000/autocomplete/compiled?vsn=2&key=1970-0-1", + "meta": { + "cache": "force-cache", + "credentials": "omit", + }, + }, + ] + `); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [], + } + `); + + return ctx; +} diff --git a/assets/js/autocomplete/__tests__/history.spec.ts b/assets/js/autocomplete/__tests__/history.spec.ts new file mode 100644 index 00000000..394442cf --- /dev/null +++ b/assets/js/autocomplete/__tests__/history.spec.ts @@ -0,0 +1,102 @@ +import { init } from './context'; + +it('records search history', async () => { + const ctx = await init(); + + await ctx.submitForm('foo1'); + + // Empty input should show all latest history items + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo1", + ], + } + `); + + await ctx.submitForm('foo2'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo2", + "(history) foo1", + ], + } + `); + + await ctx.submitForm('a complex OR (query AND bar)'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) a complex OR (query AND bar)", + "(history) foo2", + "(history) foo1", + ], + } + `); + + // Last recently used item should be on top + await ctx.submitForm('foo2'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "", + "suggestions": [ + "(history) foo2", + "(history) a complex OR (query AND bar)", + "(history) foo1", + ], + } + `); + + await ctx.setInput('a com'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "a com<>", + "suggestions": [ + "(history) a complex OR (query AND bar)", + ], + } + `); + + await ctx.setInput('f'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "f<>", + "suggestions": [ + "(history) foo2", + "(history) foo1", + "-----------", + "forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + // History items must be selectable + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "foo2<>", + "suggestions": [ + "👉 (history) foo2", + "(history) foo1", + "-----------", + "forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); +}); diff --git a/assets/js/autocomplete/__tests__/keyboard.spec.ts b/assets/js/autocomplete/__tests__/keyboard.spec.ts new file mode 100644 index 00000000..3c5a0882 --- /dev/null +++ b/assets/js/autocomplete/__tests__/keyboard.spec.ts @@ -0,0 +1,114 @@ +import { init } from './context'; + +it('supports navigation via keyboard', async () => { + const ctx = await init(); + + await ctx.setInput('f'); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [ + "👉 forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "fog<>", + "suggestions": [ + "forest 3", + "👉 fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('ArrowDown', { ctrlKey: true }); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "flower<>", + "suggestions": [ + "forest 3", + "fog 1", + "force field 1", + "👉 flower 1", + ], + } + `); + + await ctx.keyDown('ArrowUp', { ctrlKey: true }); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [ + "👉 forest 3", + "fog 1", + "force field 1", + "flower 1", + ], + } + `); + + await ctx.keyDown('Enter'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest<>", + "suggestions": [], + } + `); + + await ctx.setInput('forest, t<>, safe'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, t<>, safe", + "suggestions": [ + "artist:test 1", + ], + } + `); + + await ctx.keyDown('ArrowDown'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [ + "👉 artist:test 1", + ], + } + `); + + await ctx.keyDown('Escape'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [], + } + `); + + await ctx.setInput('forest, t<>, safe'); + await ctx.keyDown('ArrowDown'); + await ctx.keyDown('Enter'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "forest, artist:test<>, safe", + "suggestions": [], + } + `); +}); diff --git a/assets/js/autocomplete/__tests__/server-side-completions.spec.ts b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts new file mode 100644 index 00000000..fcf53eb7 --- /dev/null +++ b/assets/js/autocomplete/__tests__/server-side-completions.spec.ts @@ -0,0 +1,48 @@ +import { init } from './context'; + +it('requests server-side autocomplete if local autocomplete returns no results', async () => { + const ctx = await init(); + + await ctx.setInput('mar'); + + // 1. Request the local autocomplete index. + // 2. Request the server-side suggestions. + expect(fetch).toHaveBeenCalledTimes(2); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": "mar<>", + "suggestions": [ + "marvelous → beautiful 30", + "mare 20", + "market 10", + ], + } + `); + + await ctx.setInput(''); + + // Make sure the response caching is insensitive to term case and leading whitespace. + await ctx.setInput('mar'); + await ctx.setInput(' mar'); + await ctx.setInput(' Mar'); + await ctx.setInput(' MAR'); + + ctx.expectUi().toMatchInlineSnapshot(` + { + "input": " MAR<>", + "suggestions": [ + "marvelous → beautiful 30", + "mare 20", + "market 10", + ], + } + `); + + expect(fetch).toHaveBeenCalledTimes(2); + + // Trailing whitespace is still significant because terms may have internal spaces. + await ctx.setInput('mar '); + + expect(fetch).toHaveBeenCalledTimes(3); +});