summaryrefslogtreecommitdiff
path: root/frontend/src/lib/instrumentSearch.test.ts
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib/instrumentSearch.test.ts
downloadstrategy-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.ts121
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
+ });
+});