feat: opt-in Journal search page and JournalSearchBox
- 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.
This commit is contained in:
@@ -183,3 +183,38 @@ test('getAuthor and listAuthorPosts encode the author and map 404 to null', asyn
|
||||
assert.equal(await client.getAuthor('nobody'), null)
|
||||
assert.equal(await client.listAuthorPosts('nobody'), null)
|
||||
})
|
||||
|
||||
test('buildPostListQuery passes fields through only when set', () => {
|
||||
assert.deepEqual(buildPostListQuery({ search: 'kurki', fields: 'suggest', perPage: 5 }), {
|
||||
per_page: 5,
|
||||
search: 'kurki',
|
||||
fields: 'suggest',
|
||||
})
|
||||
assert.equal('fields' in buildPostListQuery({ search: 'kurki' }), false)
|
||||
})
|
||||
|
||||
test('suggestPosts sends search, fields=suggest and per_page, no sort, with the signal', async () => {
|
||||
const page = { data: [{ id: 1, title: 'Kurki', slug: 'kurki', excerpt: 'x', featured_image_url: null, published_at: null }], meta: { current_page: 1, last_page: 1, per_page: 5, total: 1 } }
|
||||
const inits: unknown[] = []
|
||||
const calls: Call[] = []
|
||||
const fetcher = async (path: string, query?: Record<string, string | number>, init?: { signal?: AbortSignal }) => {
|
||||
calls.push({ path, query })
|
||||
inits.push(init)
|
||||
return page
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
const client = createJournalClient(fetcher)
|
||||
assert.deepEqual(await client.suggestPosts(' kurki ', { signal }), page)
|
||||
assert.deepEqual(calls[0], { path: '/posts', query: { per_page: 5, search: 'kurki', fields: 'suggest' } })
|
||||
assert.equal((inits[0] as { signal: AbortSignal }).signal, signal)
|
||||
await client.suggestPosts('kurki', { perPage: 99 })
|
||||
assert.equal(calls[1]!.query!.per_page, JOURNAL_MAX_PER_PAGE)
|
||||
})
|
||||
|
||||
test('suggestPosts sends nothing for a term under 3 characters', async () => {
|
||||
const { fetcher, calls } = fakeFetcher({})
|
||||
const res = await createJournalClient(fetcher).suggestPosts('ąę')
|
||||
assert.equal(calls.length, 0)
|
||||
assert.deepEqual(res.data, [])
|
||||
assert.equal(res.meta.total, 0)
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ test('an override replaces one key and keeps the others', () => {
|
||||
})
|
||||
|
||||
test('a site URL map normalizes as given (grzybyfunkcjonalne shape)', () => {
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false }
|
||||
const input = { index: '/blog', post: '/blog/:slug', category: '/:category', author: '/autor/:author', tag: false, search: '/szukaj' }
|
||||
assert.deepEqual(normalizeJournalRoutes(input), input)
|
||||
})
|
||||
|
||||
@@ -85,9 +85,17 @@ test('route names are stable', () => {
|
||||
category: 'journal-category',
|
||||
tag: 'journal-tag',
|
||||
author: 'journal-author',
|
||||
search: 'journal-search',
|
||||
})
|
||||
})
|
||||
|
||||
test('the search route is off by default and takes a plain path', () => {
|
||||
assert.equal(DEFAULT_JOURNAL_ROUTES.search, false)
|
||||
assert.equal(normalizeJournalRoutes().search, false)
|
||||
assert.equal(normalizeJournalRoutes({ search: '/szukaj' }).search, '/szukaj')
|
||||
assert.throws(() => normalizeJournalRoutes({ search: 'szukaj' }), /journal\.routes\.search/)
|
||||
})
|
||||
|
||||
test('fillJournalPath encodes a value', () => {
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'a b' }), '/blog/a%20b')
|
||||
assert.equal(fillJournalPath('/blog/:slug', { slug: 'zażółć' }), '/blog/za%C5%BC%C3%B3%C5%82%C4%87')
|
||||
|
||||
203
tests/search.test.ts
Normal file
203
tests/search.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// 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)
|
||||
})
|
||||
Reference in New Issue
Block a user