summaryrefslogtreecommitdiff
path: root/frontend/src/lib/instrumentSearch.test.ts
blob: 15636be26bb1eb1dc25da8c4b8a35db036743347 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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
  });
});