import { createRoot, createSignal } from 'solid-js'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import type * as clientModule from '@/api/client'; import type { OpportunitySearch } from '@/types/Opportunities'; import type { OpportunitySearchResults } from './createOpportunitySearch'; import { createOpportunitySearch } from './createOpportunitySearch'; const requests = vi.hoisted(() => ({ searchOpportunities: vi.fn< (query: string, releasedFrom: string, signal?: AbortSignal) => Promise >(), })); vi.mock('@/api/client', async (importOriginal) => ({ ...(await importOriginal()), api: requests, })); /** A search the test answers when it chooses. */ interface SentSearch { query: string; releasedFrom: string; signal: AbortSignal | undefined; resolve: (found: OpportunitySearch) => void; reject: (err: unknown) => void; } const HELP = { prompts: [{ name: 'title', help: 'The title only.' }], syntax: [{ example: '!cloud', help: 'Not cloud.' }], }; const found = (...uuids: string[]): OpportunitySearch => ({ uuids, matches_all: false, unknown_prompts: [], ...HELP, }); let sent: SentSearch[]; let errors: string[]; let search: OpportunitySearchResults; let dispose: () => void; let setFilters: (filters: { search: string; days: number }) => void; let setReleasedFrom: (from: string) => void; /** The searches sent with a query: the help's has none. */ const searches = (): SentSearch[] => sent.filter((s) => s.query !== ''); /** Lets pending promises, Solid's effects and timers up to ms run. */ const wait = async (ms = 0): Promise => { await vi.advanceTimersByTimeAsync(ms); }; /** Starts the search with a stored query, and answers the help's request. */ async function start(query: string): Promise { createRoot((disposeRoot) => { dispose = disposeRoot; const [filters, setStored] = createSignal({ search: query, days: 7 }); const [releasedFrom, setFrom] = createSignal('2026-10-12T00:00:00Z'); setFilters = setStored; setReleasedFrom = setFrom; search = createOpportunitySearch( () => filters().search, releasedFrom, (message) => errors.push(message), ); }); sent.find((s) => s.query === '')?.resolve({ ...found(), matches_all: true }); await wait(); } const type = (text: string): void => setFilters({ search: text, days: 7 }); beforeEach(() => { vi.useFakeTimers(); sent = []; errors = []; requests.searchOpportunities.mockImplementation( (query, releasedFrom, signal) => new Promise((resolve, reject) => { sent.push({ query, releasedFrom, signal, resolve, reject }); }), ); }); afterEach(() => { dispose(); vi.clearAllMocks(); vi.useRealTimers(); }); test('loads the help at the start, with a blank query and no bound', async () => { await start(''); expect(sent.map((s) => [s.query, s.releasedFrom])).toEqual([['', '']]); expect(search.help()).toEqual(HELP); expect(search.matches()).toBeUndefined(); }); test('runs a stored search at once, and keeps its matches', async () => { await start('cloud'); expect(searches().map((s) => [s.query, s.releasedFrom])).toEqual([ ['cloud', '2026-10-12T00:00:00Z'], ]); expect(search.isSearching()).toBe(true); searches()[0]?.resolve(found('a', 'b')); await wait(); expect([...(search.matches() ?? [])]).toEqual(['a', 'b']); expect(search.isSearching()).toBe(false); }); test('waits for typing to pause, then searches for the latest text', async () => { await start(''); type('c'); await wait(200); type('cloud'); await wait(299); expect(searches()).toHaveLength(0); expect(search.isSearching()).toBe(true); await wait(1); expect(searches().map((s) => s.query)).toEqual(['cloud']); }); test('aborts a search under way for the next, and ignores its late answer', async () => { await start('cloud'); type('road'); await wait(300); expect(searches()[0]?.signal?.aborted).toBe(true); searches()[0]?.resolve(found('old')); searches()[1]?.resolve(found('new')); await wait(); expect([...(search.matches() ?? [])]).toEqual(['new']); }); test('doesn’t filter when nothing in the box can be searched for', async () => { await start('title:'); searches()[0]?.resolve({ ...found(), matches_all: true, unknown_prompts: ['foo'] }); await wait(); expect(search.matches()).toBeUndefined(); expect(search.unknownPrompts()).toEqual(['foo']); }); test('a blank box clears the matches at once, without a request', async () => { await start('cloud'); searches()[0]?.resolve(found('a')); await wait(); type(' '); await wait(300); expect(searches()).toHaveLength(1); expect(search.matches()).toBeUndefined(); expect(search.isSearching()).toBe(false); }); test('keeps the last matches after a failure, and refresh tries again', async () => { await start('cloud'); searches()[0]?.resolve(found('a')); await wait(); type('road'); await wait(300); searches()[1]?.reject(new Error('offline')); await wait(); expect([...(search.matches() ?? [])]).toEqual(['a']); expect(search.error()?.message).toBe('offline'); expect(errors).toEqual(["The search didn't run: offline"]); search.refresh(); searches()[2]?.resolve(found('b')); await wait(); expect(searches()[2]?.query).toBe('road'); expect([...(search.matches() ?? [])]).toEqual(['b']); expect(search.error()).toBeUndefined(); }); test('runs again at once for a new releasedFrom, but not for other filters', async () => { await start('cloud'); setFilters({ search: 'cloud', days: 30 }); await wait(300); expect(searches()).toHaveLength(1); setReleasedFrom('2026-10-01T00:00:00Z'); await wait(); expect(searches().map((s) => s.releasedFrom)).toEqual([ '2026-10-12T00:00:00Z', '2026-10-01T00:00:00Z', ]); }); test('a search mends help that didn’t load', async () => { createRoot((disposeRoot) => { dispose = disposeRoot; const [query, setQuery] = createSignal(''); setFilters = (filters) => setQuery(filters.search); search = createOpportunitySearch( query, () => '', (message) => errors.push(message), ); }); sent[0]?.reject(new Error('offline')); await wait(); expect(search.helpError()?.message).toBe('offline'); expect(search.help()).toBeUndefined(); type('cloud'); await wait(300); searches()[0]?.resolve(found()); await wait(); expect(search.help()).toEqual(HELP); expect(search.helpError()).toBeUndefined(); });