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
});
});
|