mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-03-17 17:10:03 +01:00
Add integration tests for various parts of the autocompletion business logic, including history suggestions
This commit is contained in:
parent
b8b3ed5982
commit
4f50d0de3e
4 changed files with 533 additions and 0 deletions
269
assets/js/autocomplete/__tests__/context.ts
Normal file
269
assets/js/autocomplete/__tests__/context.ts
Normal 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;
|
||||||
|
}
|
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal file
102
assets/js/autocomplete/__tests__/history.spec.ts
Normal 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",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal file
114
assets/js/autocomplete/__tests__/keyboard.spec.ts
Normal 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": [],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue