diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib/instrumentSearch.test.ts | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib/instrumentSearch.test.ts')
| -rw-r--r-- | frontend/src/lib/instrumentSearch.test.ts | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/frontend/src/lib/instrumentSearch.test.ts b/frontend/src/lib/instrumentSearch.test.ts new file mode 100644 index 0000000..15636be --- /dev/null +++ b/frontend/src/lib/instrumentSearch.test.ts @@ -0,0 +1,121 @@ +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<void>((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<InstrumentSearchResponse>((_, 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<InstrumentSearchResponse>((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<InstrumentSearchResponse>(() => { /* 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 + }); +}); |
