- journal.routes.search (off by default) registers journal-search, the results page for ?q= in the API's relevance order (no sort is sent), server-rendered with a Polish-aware result count. - JournalSearchBox: an icon button and a WAI-ARIA combobox with debounced ?fields=suggest suggestions, stale answers dropped, query words marked ignoring diacritics; a plain GET form without JS. - The module keeps the search path out of the route cache and the sitemap and marks it noindex, follow. - Client: suggestPosts and the fields option; useJournalLinks.searchLink.
204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
// Search helpers: term rules, diacritic-insensitive marking, plural forms,
|
|
// listbox keys, and the suggestion controller's debounce, abort and
|
|
// stale-answer handling under mocked timers. No network.
|
|
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import {
|
|
createSuggestController,
|
|
markTerms,
|
|
moveActive,
|
|
pluralForm,
|
|
searchTermFromQuery,
|
|
searchTermState,
|
|
type SuggestState,
|
|
} from '../app/utils/search.ts'
|
|
|
|
const marked = (text: string, q: string) =>
|
|
markTerms(text, q)
|
|
.map((s) => (s.mark ? `[${s.text}]` : s.text))
|
|
.join('')
|
|
|
|
test('searchTermFromQuery trims, takes the first repeated value and ignores non-strings', () => {
|
|
assert.equal(searchTermFromQuery(' borowik '), 'borowik')
|
|
assert.equal(searchTermFromQuery(['kurki', 'x']), 'kurki')
|
|
assert.equal(searchTermFromQuery(undefined), '')
|
|
assert.equal(searchTermFromQuery(null), '')
|
|
assert.equal(searchTermFromQuery([null]), '')
|
|
assert.equal([...searchTermFromQuery('ż'.repeat(500))].length, 200)
|
|
})
|
|
|
|
test('searchTermState counts characters, not UTF-16 units', () => {
|
|
assert.equal(searchTermState(''), 'empty')
|
|
assert.equal(searchTermState(' '), 'empty')
|
|
assert.equal(searchTermState('ab'), 'short')
|
|
assert.equal(searchTermState('żół'), 'ready')
|
|
assert.equal(searchTermState('ab', 2), 'ready')
|
|
})
|
|
|
|
test('markTerms ignores case and diacritics', () => {
|
|
assert.equal(marked('Sezon na grzybów zbieranie', 'grzybow'), 'Sezon na [grzybów] zbieranie')
|
|
assert.equal(marked('Łysiczki i ŁOSIE', 'lysiczki losie'), '[Łysiczki] i [ŁOSIE]')
|
|
assert.equal(marked('Kurki', 'KURKI'), '[Kurki]')
|
|
assert.equal(marked('Straße', 'strasse'), '[Straße]')
|
|
})
|
|
|
|
test('markTerms marks every occurrence and merges overlaps', () => {
|
|
assert.equal(marked('grzyb, grzyby, grzybów', 'grzyb'), '[grzyb], [grzyb]y, [grzyb]ów')
|
|
assert.equal(marked('borowik', 'bor borowik'), '[borowik]')
|
|
})
|
|
|
|
test('markTerms returns text segments only and leaves markup as text', () => {
|
|
const segs = markTerms('<b>kurki</b> & więcej', 'kurki')
|
|
assert.deepEqual(segs, [
|
|
{ text: '<b>', mark: false },
|
|
{ text: 'kurki', mark: true },
|
|
{ text: '</b> & więcej', mark: false },
|
|
])
|
|
})
|
|
|
|
test('markTerms skips one-letter words and handles empty input', () => {
|
|
assert.deepEqual(markTerms('a b c', 'a'), [{ text: 'a b c', mark: false }])
|
|
assert.deepEqual(markTerms('', 'kurki'), [])
|
|
assert.deepEqual(markTerms(null, 'kurki'), [])
|
|
assert.deepEqual(markTerms('kurki', ''), [{ text: 'kurki', mark: false }])
|
|
})
|
|
|
|
test('pluralForm gives the Polish one/few/many forms', () => {
|
|
assert.deepEqual(
|
|
[0, 1, 2, 4, 5, 12, 21, 22, 25, 102].map((n) => pluralForm('pl-PL', n)),
|
|
['many', 'one', 'few', 'few', 'many', 'many', 'many', 'few', 'many', 'few'],
|
|
)
|
|
assert.deepEqual([0, 1, 2].map((n) => pluralForm('en-US', n)), ['other', 'one', 'other'])
|
|
assert.equal(pluralForm('not a locale!!', 1), 'one')
|
|
})
|
|
|
|
test('moveActive wraps through the options', () => {
|
|
assert.equal(moveActive(-1, 3, 'ArrowDown'), 0)
|
|
assert.equal(moveActive(2, 3, 'ArrowDown'), 0)
|
|
assert.equal(moveActive(-1, 3, 'ArrowUp'), 2)
|
|
assert.equal(moveActive(0, 3, 'ArrowUp'), 2)
|
|
assert.equal(moveActive(1, 3, 'ArrowUp'), 0)
|
|
assert.equal(moveActive(0, 0, 'ArrowDown'), null)
|
|
assert.equal(moveActive(0, 3, 'Enter'), null)
|
|
})
|
|
|
|
type Row = { id: number; term: string }
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (v: T) => void
|
|
let reject!: (e: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
const flush = () => new Promise<void>((r) => setImmediate(r))
|
|
|
|
function harness(t: import('node:test').TestContext) {
|
|
t.mock.timers.enable({ apis: ['setTimeout'] })
|
|
const calls: Array<{ term: string; signal: AbortSignal; answer: ReturnType<typeof deferred<{ data: Row[]; meta: { total: number } }>> }> = []
|
|
const states: Array<SuggestState<Row>> = []
|
|
const ctl = createSuggestController<Row>({
|
|
fetch: (term, signal) => {
|
|
const answer = deferred<{ data: Row[]; meta: { total: number } }>()
|
|
calls.push({ term, signal, answer })
|
|
return answer.promise
|
|
},
|
|
onChange: (s) => states.push(s),
|
|
})
|
|
const last = () => states[states.length - 1]!
|
|
return { ctl, calls, last, tick: (ms: number) => t.mock.timers.tick(ms) }
|
|
}
|
|
|
|
test('suggest: one request per pause, after the debounce', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('bor')
|
|
h.tick(100)
|
|
h.ctl.input('boro')
|
|
h.tick(100)
|
|
h.ctl.input('borow')
|
|
h.tick(249)
|
|
assert.equal(h.calls.length, 0)
|
|
h.tick(1)
|
|
assert.deepEqual(
|
|
h.calls.map((c) => c.term),
|
|
['borow'],
|
|
)
|
|
assert.equal(h.last().status, 'loading')
|
|
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'borow' }], meta: { total: 7 } })
|
|
await flush()
|
|
assert.equal(h.last().status, 'done')
|
|
assert.deepEqual(h.last().rows, [{ id: 1, term: 'borow' }])
|
|
assert.equal(h.last().total, 7)
|
|
})
|
|
|
|
test('suggest: short terms send nothing and clear the rows', (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('bo')
|
|
h.tick(1000)
|
|
assert.equal(h.calls.length, 0)
|
|
assert.equal(h.last().status, 'idle')
|
|
})
|
|
|
|
test('suggest: a newer term aborts the running request and drops its answer', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('kurki')
|
|
h.tick(250)
|
|
h.ctl.input('kurkiw')
|
|
assert.equal(h.calls[0]!.signal.aborted, true)
|
|
h.tick(250)
|
|
h.calls[1]!.answer.resolve({ data: [{ id: 2, term: 'kurkiw' }], meta: { total: 1 } })
|
|
await flush()
|
|
h.calls[0]!.answer.resolve({ data: [{ id: 1, term: 'kurki' }], meta: { total: 1 } })
|
|
await flush()
|
|
assert.deepEqual(h.last().rows, [{ id: 2, term: 'kurkiw' }])
|
|
assert.equal(h.last().term, 'kurkiw')
|
|
})
|
|
|
|
test('suggest: the spinner shows only after the delay', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('rydze')
|
|
h.tick(250)
|
|
h.tick(299)
|
|
assert.equal(h.last().loading, false)
|
|
h.tick(1)
|
|
assert.equal(h.last().loading, true)
|
|
h.calls[0]!.answer.resolve({ data: [], meta: { total: 0 } })
|
|
await flush()
|
|
assert.equal(h.last().loading, false)
|
|
assert.equal(h.last().status, 'done')
|
|
})
|
|
|
|
test('suggest: a failed request ends in error without throwing', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('opieńki')
|
|
h.tick(250)
|
|
h.calls[0]!.answer.reject(new Error('429'))
|
|
await flush()
|
|
assert.equal(h.last().status, 'error')
|
|
assert.deepEqual(h.last().rows, [])
|
|
})
|
|
|
|
test('suggest: the same term again sends no second request', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('maślaki')
|
|
h.tick(250)
|
|
h.ctl.input('maślaki ')
|
|
h.tick(1000)
|
|
assert.equal(h.calls.length, 1)
|
|
})
|
|
|
|
test('suggest: dispose aborts and ignores the answer', async (t) => {
|
|
const h = harness(t)
|
|
h.ctl.input('gąski')
|
|
h.tick(250)
|
|
h.ctl.dispose()
|
|
assert.equal(h.calls[0]!.signal.aborted, true)
|
|
const before = h.last()
|
|
h.calls[0]!.answer.resolve({ data: [{ id: 9, term: 'gąski' }], meta: { total: 1 } })
|
|
await flush()
|
|
assert.equal(h.last(), before)
|
|
})
|