// 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('kurki & więcej', 'kurki') assert.deepEqual(segs, [ { text: '', mark: false }, { text: 'kurki', mark: true }, { text: ' & 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() { let resolve!: (v: T) => void let reject!: (e: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } const flush = () => new Promise((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> }> = [] const states: Array> = [] const ctl = createSuggestController({ 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) })