import { describe, expect, it, vi } from 'vitest'; import { createSearchRunner, SEARCH_TIMEOUT_MSG, SEARCH_UNAVAILABLE_MSG, type SearchFetcher, type SearchHandlers } from './instrumentSearch'; import type { Instrument, InstrumentSearchResponse } from './types'; function inst(symbol: string): Instrument { return { symbol, market: 'SH', asset_type: 'stock', name: symbol, currency: 'CNY', source: 'eastmoney' }; } function ok(items: Instrument[]): InstrumentSearchResponse { return { items, source: 'provider_suggest', status: 'ok' }; } function trackedHandlers(): SearchHandlers & { events: string[] } { const events: string[] = []; return { events, onBusy: () => events.push('busy'), onResult: (res) => events.push(`result:${res.items.length}:${res.status}`), onError: (m) => events.push(`error:${m}`), onFinish: () => events.push('finish') }; } const flush = () => new Promise((r) => setTimeout(r, 0)); describe('instrument search coordinator', () => { it('successful provider response shows items and clears the spinner', async () => { const h = trackedHandlers(); const fetch: SearchFetcher = vi.fn(async () => ok([inst('600000')])); const runner = createSearchRunner(fetch, h); runner.runNow('600000'); await flush(); expect(h.events).toEqual(['busy', 'result:1:ok', 'finish']); }); it('unavailable status shows an explicit Chinese message and clears items', async () => { const h = trackedHandlers(); const fetch: SearchFetcher = async () => ({ items: [], source: 'none', status: 'unavailable: providers down' }); const runner = createSearchRunner(fetch, h); runner.runNow('600000'); await flush(); expect(h.events).toContain(`error:${SEARCH_UNAVAILABLE_MSG}`); expect(h.events[h.events.length - 1]).toBe('finish'); }); it('provider rejection surfaces a visible failure and the spinner is cleared in finally', async () => { const h = trackedHandlers(); const fetch: SearchFetcher = async () => { throw { status: 502, message: 'bad gateway' }; }; const runner = createSearchRunner(fetch, h); runner.runNow('600000'); await flush(); expect(h.events.some((e) => e.startsWith('error:'))).toBe(true); expect(h.events[h.events.length - 1]).toBe('finish'); }); it('a hard timer aborts the hung request and shows the timeout wording', async () => { const h = trackedHandlers(); const fetch: SearchFetcher = vi.fn(async (_q: string, signal?: AbortSignal) => new Promise((_, reject) => { signal?.addEventListener('abort', () => reject(new DOMException('x', 'AbortError'))); })); const runner = createSearchRunner(fetch, h, { timeoutMs: 30 }); runner.runNow('600000'); await flush(); await new Promise((r) => setTimeout(r, 60)); expect(h.events).toContain(`error:${SEARCH_TIMEOUT_MSG}`); expect(h.events[h.events.length - 1]).toBe('finish'); // spinner never hangs expect(fetch).toHaveBeenCalledTimes(1); }); it('stale prior query: its late result never repaints and never hangs the spinner', async () => { const h = trackedHandlers(); let releaseA!: (v: InstrumentSearchResponse) => void; let calls = 0; const fetch: SearchFetcher = vi.fn(async () => { calls += 1; if (calls === 1) { return new Promise((resolve) => { releaseA = resolve; }); } return ok([inst('600000')]); }); const runner = createSearchRunner(fetch, h); runner.runNow('old'); await flush(); const busyCount = h.events.filter((e) => e === 'busy').length; runner.runNow('new'); await flush(); // first request aborted; the slow promise is released AFTER the swap and // resolves into a stale seq → no UI effect, no extra finish releaseA(ok([inst('000001')])); await flush(); const busyAfter = h.events.filter((e) => e === 'busy').length; expect(busyAfter).toBe(2); expect(h.events[h.events.length - 1]).toBe('finish'); expect(h.events[busyCount - 1]).toBe('busy'); expect(h.events.join('|')).not.toContain('result:1:ok|finish|result'); expect(h.events.filter((e) => e.startsWith('result')).length).toBe(1); }); it('dispose invalidates in-flight callbacks entirely', async () => { const h = trackedHandlers(); let pendings = 0; const fetch: SearchFetcher = async () => { pendings += 1; return new Promise(() => { /* pending */ }); }; const runner = createSearchRunner(fetch, h); runner.runNow('600000'); await flush(); const eventsAtDispose = h.events.length; runner.dispose(); await flush(); expect(h.events.length).toBe(eventsAtDispose); // nothing further fired }); });