Add integration tests for various parts of the autocompletion business logic, including history suggestions

This commit is contained in:
MareStare 2025-03-04 04:59:28 +00:00
parent b8b3ed5982
commit 4f50d0de3e
4 changed files with 533 additions and 0 deletions

View file

@ -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 `<body>` 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 = `
<form>
<input
class="test-input"
data-autocomplete="multi-tags"
data-autocomplete-condition="enable_search_ac"
data-autocomplete-history-id="search-history"
/>
</form>
`;
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<string, unknown> = {};
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<TestContext> {
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;
}

View file

@ -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",
],
}
`);
});

View file

@ -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": [],
}
`);
});

View file

@ -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);
});