@openfinclaw/findoo-datahub-plugin 2026.3.10 → 2026.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * L5 — Data Freshness E2E (Live)
3
+ *
4
+ * Verifies data quality and freshness through the gateway HTTP API,
5
+ * simulating what a browser-based consumer would experience.
6
+ *
7
+ * Tests cover:
8
+ * - OHLCV timestamp recency
9
+ * - Ticker volume validity
10
+ * - Cache effectiveness (response time improvement on repeat queries)
11
+ * - Cross-market data consistency
12
+ *
13
+ * Prerequisites:
14
+ * - Gateway running at http://localhost:18789
15
+ * - findoo-datahub-plugin loaded
16
+ * - DataHub API reachable
17
+ *
18
+ * Run:
19
+ * LIVE=1 npx vitest run extensions/findoo-datahub-plugin/test/e2e/l5-browser/data-freshness.live.test.ts
20
+ */
21
+
22
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
23
+
24
+ const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
25
+ const AUTH_TOKEN = process.env.AUTH_TOKEN ?? "openclaw-local";
26
+ const DATAHUB_API_URL = process.env.DATAHUB_API_URL ?? "http://43.134.61.136:8088";
27
+ const DATAHUB_API_KEY =
28
+ process.env.DATAHUB_API_KEY ??
29
+ process.env.DATAHUB_PASSWORD ??
30
+ "98ffa5c5-1ec6-4735-8e0c-715a5eca1a8d";
31
+ const DATAHUB_USERNAME = process.env.DATAHUB_USERNAME ?? "admin";
32
+
33
+ const SKIP =
34
+ process.env.L5_SKIP === "1" ||
35
+ process.env.CI === "true" ||
36
+ (process.env.LIVE !== "1" && process.env.CLAWDBOT_LIVE_TEST !== "1");
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // DataHub direct API helpers (simulating what the plugin does under the hood)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const authHeader = `Basic ${btoa(`${DATAHUB_USERNAME}:${DATAHUB_API_KEY}`)}`;
43
+
44
+ async function queryDataHub(
45
+ path: string,
46
+ params?: Record<string, string>,
47
+ ): Promise<{ results: unknown[]; elapsed: number }> {
48
+ const url = new URL(`${DATAHUB_API_URL}/api/v1/${path}`);
49
+ if (params) {
50
+ for (const [k, v] of Object.entries(params)) {
51
+ url.searchParams.set(k, v);
52
+ }
53
+ }
54
+
55
+ const start = performance.now();
56
+ const resp = await fetch(url.toString(), {
57
+ headers: { Authorization: authHeader },
58
+ signal: AbortSignal.timeout(30_000),
59
+ });
60
+ const elapsed = performance.now() - start;
61
+
62
+ if (resp.status === 204) return { results: [], elapsed };
63
+
64
+ const text = await resp.text();
65
+ if (!resp.ok) {
66
+ throw new Error(`DataHub ${path} returned ${resp.status}: ${text.slice(0, 200)}`);
67
+ }
68
+
69
+ const payload = JSON.parse(text) as { results?: unknown[] };
70
+ return { results: payload.results ?? [], elapsed };
71
+ }
72
+
73
+ type OHLCVRow = {
74
+ date?: string;
75
+ trade_date?: string;
76
+ timestamp?: number;
77
+ open: number;
78
+ high: number;
79
+ low: number;
80
+ close: number;
81
+ volume?: number;
82
+ vol?: number;
83
+ };
84
+
85
+ function parseTimestamp(row: OHLCVRow): number {
86
+ const ts = row.date ?? row.trade_date ?? row.timestamp;
87
+ if (!ts) return 0;
88
+ return typeof ts === "number" ? ts : new Date(String(ts)).getTime();
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Tests
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe.skipIf(SKIP)("L5 — Data Freshness E2E (Live)", { timeout: 120_000 }, () => {
96
+ beforeAll(async () => {
97
+ // Verify DataHub is reachable
98
+ try {
99
+ const resp = await fetch(`${DATAHUB_API_URL}/api/v1/coverage/endpoints`, {
100
+ headers: { Authorization: authHeader },
101
+ signal: AbortSignal.timeout(10_000),
102
+ });
103
+ if (!resp.ok) throw new Error(`DataHub unreachable: ${resp.status}`);
104
+ } catch (err) {
105
+ throw new Error(`DataHub not reachable at ${DATAHUB_API_URL}.\n` + `Original error: ${err}`);
106
+ }
107
+ });
108
+
109
+ // === 1. OHLCV Timestamp Recency ===
110
+
111
+ describe("1. OHLCV timestamp recency", () => {
112
+ it("1.1 equity OHLCV latest bar is within 7 days (trading days)", async () => {
113
+ const { results } = await queryDataHub("equity/price/historical", {
114
+ symbol: "600519.SH",
115
+ provider: "tushare",
116
+ limit: "5",
117
+ });
118
+ expect(results.length).toBeGreaterThan(0);
119
+
120
+ const rows = results as OHLCVRow[];
121
+ const latestTs = Math.max(...rows.map(parseTimestamp));
122
+ const now = Date.now();
123
+ const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
124
+
125
+ // Latest bar should be within 7 calendar days (accounts for weekends + holidays)
126
+ expect(
127
+ now - latestTs,
128
+ `Latest equity bar is too old: ${new Date(latestTs).toISOString()}`,
129
+ ).toBeLessThan(sevenDaysMs);
130
+ });
131
+
132
+ it("1.2 crypto OHLCV latest bar is within 2 days", async () => {
133
+ const { results } = await queryDataHub("crypto/price/historical", {
134
+ symbol: "BTC/USDT",
135
+ provider: "ccxt",
136
+ });
137
+ expect(results.length).toBeGreaterThan(0);
138
+
139
+ const rows = results as OHLCVRow[];
140
+ const latestTs = Math.max(...rows.map(parseTimestamp));
141
+ const now = Date.now();
142
+ const twoDaysMs = 2 * 24 * 60 * 60 * 1000;
143
+
144
+ // Crypto trades 24/7 — latest bar should be very recent
145
+ expect(
146
+ now - latestTs,
147
+ `Latest crypto bar is too old: ${new Date(latestTs).toISOString()}`,
148
+ ).toBeLessThan(twoDaysMs);
149
+ });
150
+
151
+ it("1.3 OHLCV bars are chronologically sorted", async () => {
152
+ const { results } = await queryDataHub("equity/price/historical", {
153
+ symbol: "600519.SH",
154
+ provider: "tushare",
155
+ limit: "20",
156
+ });
157
+ const rows = results as OHLCVRow[];
158
+ const timestamps = rows.map(parseTimestamp).filter((t) => t > 0);
159
+
160
+ for (let i = 1; i < timestamps.length; i++) {
161
+ // Allow either ascending or descending order, but must be consistent
162
+ if (i === 1) continue; // Need at least 2 pairs
163
+ const dir = timestamps[1]! > timestamps[0]! ? 1 : -1;
164
+ const current = (timestamps[i]! - timestamps[i - 1]!) * dir;
165
+ expect(
166
+ current,
167
+ `Bars not sorted at index ${i}: ${timestamps[i - 1]} -> ${timestamps[i]}`,
168
+ ).toBeGreaterThanOrEqual(0);
169
+ }
170
+ });
171
+ });
172
+
173
+ // === 2. Ticker Data Validity ===
174
+
175
+ describe("2. Ticker data validity", () => {
176
+ it("2.1 crypto ticker has positive 24h volume", async () => {
177
+ const { results } = await queryDataHub("crypto/market/ticker", {
178
+ symbol: "BTC/USDT",
179
+ exchange: "binance",
180
+ });
181
+ // Ticker endpoint may return array or single entry
182
+ const data = results as Array<Record<string, unknown>>;
183
+ if (data.length > 0) {
184
+ const ticker = data[0]!;
185
+ const volume = Number(ticker.volume ?? ticker.quoteVolume ?? ticker.baseVolume ?? 0);
186
+ expect(volume, "24h volume should be positive").toBeGreaterThan(0);
187
+ }
188
+ });
189
+
190
+ it("2.2 crypto ticker price is in reasonable range for BTC", async () => {
191
+ const { results } = await queryDataHub("crypto/market/ticker", {
192
+ symbol: "BTC/USDT",
193
+ exchange: "binance",
194
+ });
195
+ const data = results as Array<Record<string, unknown>>;
196
+ if (data.length > 0) {
197
+ const ticker = data[0]!;
198
+ const price = Number(ticker.last ?? ticker.close ?? ticker.bid ?? 0);
199
+ // BTC should be between $10,000 and $500,000 (reasonable 2024-2027 range)
200
+ expect(price, "BTC price sanity check").toBeGreaterThan(10_000);
201
+ expect(price, "BTC price sanity check").toBeLessThan(500_000);
202
+ }
203
+ });
204
+
205
+ it("2.3 equity ticker returns valid close price", async () => {
206
+ const { results } = await queryDataHub("equity/price/historical", {
207
+ symbol: "600519.SH",
208
+ provider: "tushare",
209
+ limit: "1",
210
+ });
211
+ expect(results.length).toBeGreaterThan(0);
212
+ const row = results[0] as OHLCVRow;
213
+ // Maotai price should be in reasonable range (1000-3000 CNY)
214
+ expect(Number(row.close)).toBeGreaterThan(500);
215
+ expect(Number(row.close)).toBeLessThan(5000);
216
+ });
217
+ });
218
+
219
+ // === 3. Cache Effectiveness ===
220
+
221
+ describe("3. Cache and response time", () => {
222
+ it("3.1 second request for same symbol is faster (cache hit)", async () => {
223
+ const symbol = "000300.SH";
224
+ const params = { symbol, provider: "tushare", limit: "30" };
225
+
226
+ // Cold request
227
+ const first = await queryDataHub("index/price/historical", params);
228
+ expect(first.results.length).toBeGreaterThan(0);
229
+
230
+ // Warm request (should hit server-side or HTTP cache)
231
+ const second = await queryDataHub("index/price/historical", params);
232
+ expect(second.results.length).toBeGreaterThan(0);
233
+
234
+ // Cache should improve response time (allow generous 3x ratio since
235
+ // network variance is high; the key assertion is that it doesn't error)
236
+ // We mainly verify both requests succeed with same data
237
+ expect(second.results.length).toBe(first.results.length);
238
+ });
239
+
240
+ it("3.2 repeated crypto queries return consistent data", async () => {
241
+ const params = { symbol: "BTC/USDT", provider: "ccxt", limit: "5" };
242
+
243
+ const r1 = await queryDataHub("crypto/price/historical", params);
244
+ const r2 = await queryDataHub("crypto/price/historical", params);
245
+
246
+ expect(r1.results.length).toBeGreaterThan(0);
247
+ expect(r2.results.length).toBeGreaterThan(0);
248
+
249
+ // Same historical data should be returned (recent bar may differ slightly)
250
+ const rows1 = r1.results as OHLCVRow[];
251
+ const rows2 = r2.results as OHLCVRow[];
252
+ // First bar should match (historical data is stable)
253
+ if (rows1.length > 1 && rows2.length > 1) {
254
+ expect(parseTimestamp(rows1[0]!)).toBe(parseTimestamp(rows2[0]!));
255
+ }
256
+ });
257
+ });
258
+
259
+ // === 4. Cross-Market Data Consistency ===
260
+
261
+ describe("4. Cross-market consistency", () => {
262
+ it("4.1 OHLCV has all required fields (OHLCV)", async () => {
263
+ const { results } = await queryDataHub("equity/price/historical", {
264
+ symbol: "AAPL",
265
+ provider: "massive",
266
+ limit: "5",
267
+ });
268
+ expect(results.length).toBeGreaterThan(0);
269
+ const row = results[0] as Record<string, unknown>;
270
+ // Must have OHLCV fields (names may vary by provider)
271
+ const hasOpen = row.open !== undefined || row.Open !== undefined;
272
+ const hasClose = row.close !== undefined || row.Close !== undefined;
273
+ const hasHigh = row.high !== undefined || row.High !== undefined;
274
+ const hasLow = row.low !== undefined || row.Low !== undefined;
275
+ expect(hasOpen, "Missing open field").toBe(true);
276
+ expect(hasClose, "Missing close field").toBe(true);
277
+ expect(hasHigh, "Missing high field").toBe(true);
278
+ expect(hasLow, "Missing low field").toBe(true);
279
+ });
280
+
281
+ it("4.2 macro data has reasonable values", async () => {
282
+ const { results } = await queryDataHub("economy/cpi", {
283
+ limit: "5",
284
+ });
285
+ expect(results.length).toBeGreaterThan(0);
286
+ const row = results[0] as Record<string, unknown>;
287
+ // CPI data should exist and have some numeric field
288
+ const hasNumeric = Object.values(row).some(
289
+ (v) => typeof v === "number" || (typeof v === "string" && /^\d+\.?\d*$/.test(v)),
290
+ );
291
+ expect(hasNumeric, "CPI data should contain numeric values").toBe(true);
292
+ });
293
+
294
+ it("4.3 crypto and equity queries use correct providers", async () => {
295
+ // Verify crypto routes to ccxt
296
+ const cryptoRes = await queryDataHub("crypto/price/historical", {
297
+ symbol: "ETH/USDT",
298
+ provider: "ccxt",
299
+ limit: "3",
300
+ });
301
+ expect(cryptoRes.results.length).toBeGreaterThan(0);
302
+
303
+ // Verify equity routes to tushare for A-shares
304
+ const equityRes = await queryDataHub("equity/price/historical", {
305
+ symbol: "000001.SZ",
306
+ provider: "tushare",
307
+ limit: "3",
308
+ });
309
+ expect(equityRes.results.length).toBeGreaterThan(0);
310
+ });
311
+ });
312
+
313
+ // === 5. Error Handling ===
314
+
315
+ describe("5. Error handling", () => {
316
+ it("5.1 invalid endpoint returns error, not crash", async () => {
317
+ try {
318
+ await queryDataHub("nonexistent/endpoint");
319
+ // If it succeeds with empty results, that's also fine
320
+ } catch (err) {
321
+ // Should be a clean HTTP error, not a crash
322
+ expect(err instanceof Error).toBe(true);
323
+ expect((err as Error).message).toContain("DataHub");
324
+ }
325
+ });
326
+
327
+ it("5.2 invalid symbol returns empty results or error", async () => {
328
+ const { results } = await queryDataHub("equity/price/historical", {
329
+ symbol: "INVALID_SYMBOL_XYZ_999",
330
+ provider: "tushare",
331
+ });
332
+ // Should return empty array, not crash
333
+ expect(Array.isArray(results)).toBe(true);
334
+ });
335
+
336
+ it("5.3 request with empty params does not crash", async () => {
337
+ try {
338
+ const { results } = await queryDataHub("economy/cpi");
339
+ expect(Array.isArray(results)).toBe(true);
340
+ } catch (err) {
341
+ // Acceptable: clean error message
342
+ expect(err instanceof Error).toBe(true);
343
+ }
344
+ });
345
+ });
346
+
347
+ // === 6. Gateway HTTP Route Integration ===
348
+
349
+ describe("6. Gateway HTTP route integration", () => {
350
+ it("6.1 gateway /health is reachable", async () => {
351
+ const resp = await fetch(`${GATEWAY_URL}/health`, {
352
+ signal: AbortSignal.timeout(5_000),
353
+ });
354
+ expect(resp.status).toBe(200);
355
+ });
356
+
357
+ it("6.2 gateway serves the control UI", async () => {
358
+ const resp = await fetch(`${GATEWAY_URL}/`, {
359
+ headers: { Cookie: `openclaw-token=${AUTH_TOKEN}` },
360
+ signal: AbortSignal.timeout(5_000),
361
+ });
362
+ expect(resp.status).toBe(200);
363
+ const html = await resp.text();
364
+ expect(html).toContain("<!DOCTYPE html>");
365
+ });
366
+
367
+ it("6.3 skills endpoint lists datahub skills", async () => {
368
+ const resp = await fetch(`${GATEWAY_URL}/skills`, {
369
+ headers: { Cookie: `openclaw-token=${AUTH_TOKEN}` },
370
+ signal: AbortSignal.timeout(10_000),
371
+ });
372
+ expect(resp.status).toBe(200);
373
+ const html = await resp.text();
374
+ // Should contain some skill references
375
+ const hasContent = html.length > 500;
376
+ expect(hasContent, "Skills page should have substantial content").toBe(true);
377
+ });
378
+ });
379
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * L5 — Market Data Chat E2E (Live)
3
+ *
4
+ * Verifies data retrieval through the Control UI /chat interface.
5
+ * Sends natural-language queries and validates that the LLM invokes
6
+ * the correct fin_data_* tools and returns meaningful results.
7
+ *
8
+ * This is a LIVE test — requires:
9
+ * - Gateway running at http://localhost:18789 with LLM configured
10
+ * - findoo-datahub-plugin loaded
11
+ * - DataHub API reachable
12
+ * - Valid LLM API key in gateway config
13
+ *
14
+ * Run:
15
+ * LIVE=1 npx vitest run extensions/findoo-datahub-plugin/test/e2e/l5-browser/market-data-chat.live.test.ts
16
+ *
17
+ * These tests have longer timeouts because LLM responses are involved.
18
+ */
19
+
20
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
21
+
22
+ const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
23
+ const AUTH_TOKEN = process.env.AUTH_TOKEN ?? "openclaw-local";
24
+ const SKIP =
25
+ process.env.L5_SKIP === "1" ||
26
+ process.env.CI === "true" ||
27
+ (process.env.LIVE !== "1" && process.env.CLAWDBOT_LIVE_TEST !== "1");
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Chat API helpers
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * OpenAI-compatible chat completion response shape.
35
+ */
36
+ type ChatChoice = {
37
+ index: number;
38
+ message: {
39
+ role: string;
40
+ content: string | null;
41
+ tool_calls?: Array<{
42
+ id: string;
43
+ type: string;
44
+ function: { name: string; arguments: string };
45
+ }>;
46
+ };
47
+ finish_reason: string;
48
+ };
49
+
50
+ type ChatResponse = {
51
+ id?: string;
52
+ choices?: ChatChoice[];
53
+ content?: string;
54
+ tool_calls?: Array<{ name: string; arguments: Record<string, unknown> }>;
55
+ error?: string | { message: string };
56
+ };
57
+
58
+ /**
59
+ * Send a chat message via the OpenAI-compatible /v1/chat/completions endpoint.
60
+ * This is the actual HTTP chat API the gateway exposes.
61
+ */
62
+ async function sendChatMessage(message: string, timeoutMs = 120_000): Promise<ChatResponse> {
63
+ const resp = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
64
+ method: "POST",
65
+ headers: {
66
+ Authorization: `Bearer ${AUTH_TOKEN}`,
67
+ "Content-Type": "application/json",
68
+ },
69
+ body: JSON.stringify({
70
+ model: "default",
71
+ messages: [{ role: "user", content: message }],
72
+ stream: false,
73
+ }),
74
+ signal: AbortSignal.timeout(timeoutMs),
75
+ });
76
+
77
+ if (!resp.ok) {
78
+ const text = await resp.text();
79
+ throw new Error(`/v1/chat/completions returned ${resp.status}: ${text.slice(0, 500)}`);
80
+ }
81
+
82
+ return resp.json() as Promise<ChatResponse>;
83
+ }
84
+
85
+ /**
86
+ * Send a chat message and extract the assistant's text reply.
87
+ */
88
+ async function sendChatWs(message: string, timeoutMs = 120_000): Promise<string> {
89
+ try {
90
+ const resp = await sendChatMessage(message, timeoutMs);
91
+ // OpenAI format: choices[0].message.content
92
+ if (resp.choices && resp.choices.length > 0) {
93
+ return resp.choices[0]!.message.content ?? JSON.stringify(resp);
94
+ }
95
+ if (resp.content) return resp.content;
96
+ return JSON.stringify(resp);
97
+ } catch (err) {
98
+ throw new Error(
99
+ `Chat request failed for message: "${message}". ` +
100
+ `Ensure gateway is running at ${GATEWAY_URL} with LLM configured. ` +
101
+ `(${err instanceof Error ? err.message : err})`,
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Extract tool call names from a chat response.
108
+ */
109
+ function extractToolCalls(resp: ChatResponse): string[] {
110
+ // OpenAI format: choices[0].message.tool_calls
111
+ if (resp.choices && resp.choices.length > 0) {
112
+ const tc = resp.choices[0]!.message.tool_calls;
113
+ if (tc) return tc.map((t) => t.function.name);
114
+ }
115
+ if (resp.tool_calls) {
116
+ return resp.tool_calls.map((tc) => tc.name);
117
+ }
118
+ return [];
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Tests
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe.skipIf(SKIP)("L5 — Market Data Chat E2E (Live)", { timeout: 180_000 }, () => {
126
+ beforeAll(async () => {
127
+ // Verify gateway is reachable and has chat capability
128
+ try {
129
+ const health = await fetch(`${GATEWAY_URL}/health`, {
130
+ signal: AbortSignal.timeout(5_000),
131
+ });
132
+ if (!health.ok) throw new Error(`Gateway health check failed: ${health.status}`);
133
+ } catch (err) {
134
+ throw new Error(
135
+ `Gateway not reachable at ${GATEWAY_URL}. ` +
136
+ `Start with: openclaw gateway run --port 18789\n` +
137
+ `Original error: ${err}`,
138
+ );
139
+ }
140
+ });
141
+
142
+ // === 1. BTC price query ===
143
+
144
+ it("1.1 asking BTC price returns a numeric price", async () => {
145
+ const reply = await sendChatWs("BTC 现在什么价格?简短回答", 120_000);
146
+ // Response should contain a price-like number (5 or 6 digits for BTC)
147
+ const hasPrice = /\d{4,6}/.test(reply) || /\$[\d,]+/.test(reply);
148
+ expect(hasPrice, `Response should contain BTC price: ${reply.slice(0, 200)}`).toBe(true);
149
+ });
150
+
151
+ it("1.2 BTC price query triggers fin_crypto or fin_data_ohlcv tool", async () => {
152
+ const resp = await sendChatMessage("查询 BTC 最新价格", 120_000);
153
+ const toolCalls = extractToolCalls(resp);
154
+ // Should invoke crypto-related tools
155
+ const hasCryptoTool = toolCalls.some(
156
+ (t) => t === "fin_crypto" || t === "fin_data_ohlcv" || t.includes("crypto"),
157
+ );
158
+ // Tool call detection depends on response format; skip assertion if no tool info
159
+ if (toolCalls.length > 0) {
160
+ expect(hasCryptoTool, `Expected crypto tool, got: ${toolCalls.join(", ")}`).toBe(true);
161
+ }
162
+ });
163
+
164
+ // === 2. A-share index query ===
165
+
166
+ it("2.1 querying SSE Composite index returns data", async () => {
167
+ const reply = await sendChatWs("查看上证指数最新行情,简短回答", 120_000);
168
+ // Should mention the index or contain a 4-digit number (index level)
169
+ const hasIndexData =
170
+ /上证/.test(reply) || /\d{4}/.test(reply) || /000001/.test(reply) || /SSE/.test(reply);
171
+ expect(hasIndexData, `Response should reference SSE index: ${reply.slice(0, 200)}`).toBe(true);
172
+ });
173
+
174
+ it("2.2 querying A-share K-line triggers OHLCV tools", async () => {
175
+ const resp = await sendChatMessage("获取茅台 600519.SH 最近 5 天的日 K 线数据", 120_000);
176
+ // The response should contain price data
177
+ const content = resp.content ?? JSON.stringify(resp);
178
+ const hasData =
179
+ /close|收盘|open|开盘|high|最高|low|最低/.test(content) ||
180
+ /600519/.test(content) ||
181
+ /茅台/.test(content);
182
+ if (content.length > 10) {
183
+ expect(hasData, `Response should contain K-line data: ${content.slice(0, 200)}`).toBe(true);
184
+ }
185
+ });
186
+
187
+ // === 3. Macro data query ===
188
+
189
+ it("3.1 querying CPI data returns macro indicator", async () => {
190
+ const reply = await sendChatWs("中国最新 CPI 数据是多少?简短回答", 120_000);
191
+ const hasMacroData =
192
+ /CPI/.test(reply) || /\d+\.\d+/.test(reply) || /消费/.test(reply) || /物价/.test(reply);
193
+ expect(hasMacroData, `Response should contain CPI data: ${reply.slice(0, 200)}`).toBe(true);
194
+ });
195
+
196
+ // === 4. DeFi data query ===
197
+
198
+ it("4.1 querying DeFi TVL returns protocol data", async () => {
199
+ const reply = await sendChatWs("当前 DeFi 总 TVL 是多少?列出 top 3 协议,简短回答", 120_000);
200
+ const hasDefiData =
201
+ /TVL/.test(reply) ||
202
+ /\$\d/.test(reply) ||
203
+ /billion/i.test(reply) ||
204
+ /Lido|Aave|MakerDAO|Maker|EigenLayer|Uniswap/i.test(reply);
205
+ expect(hasDefiData, `Response should contain DeFi TVL data: ${reply.slice(0, 200)}`).toBe(true);
206
+ });
207
+
208
+ // === 5. Technical analysis query ===
209
+
210
+ it("5.1 querying RSI triggers fin_ta tool", async () => {
211
+ const reply = await sendChatWs("计算茅台 600519.SH 的 RSI 指标,简短回答", 120_000);
212
+ const hasTAData =
213
+ /RSI/.test(reply) ||
214
+ /\d{1,3}\.\d/.test(reply) ||
215
+ /超买|超卖|overbought|oversold/i.test(reply);
216
+ expect(hasTAData, `Response should contain RSI data: ${reply.slice(0, 200)}`).toBe(true);
217
+ });
218
+
219
+ // === 6. Multi-tool chain query ===
220
+
221
+ it("6.1 complex query triggers multiple tools", async () => {
222
+ const reply = await sendChatWs(
223
+ "比较 BTC 和黄金最近一周的走势,简要分析,不超过 100 字",
224
+ 120_000,
225
+ );
226
+ // Should mention both assets
227
+ const hasBTC = /BTC|比特币|bitcoin/i.test(reply);
228
+ const hasGold = /黄金|gold|XAU/i.test(reply);
229
+ expect(hasBTC || hasGold, `Response should reference both assets: ${reply.slice(0, 300)}`).toBe(
230
+ true,
231
+ );
232
+ });
233
+
234
+ // === 7. Error handling in chat ===
235
+
236
+ it("7.1 querying invalid symbol returns graceful error", async () => {
237
+ const reply = await sendChatWs("查询 ZZZZZ999.XX 的股票价格", 120_000);
238
+ // Should not crash; should return a message (possibly an error explanation)
239
+ expect(reply.length).toBeGreaterThan(0);
240
+ // Should not contain stack traces
241
+ expect(reply).not.toContain("at Object.");
242
+ expect(reply).not.toContain("TypeError");
243
+ });
244
+
245
+ // === 8. Tool result rendering ===
246
+
247
+ it("8.1 chat response is readable and structured", async () => {
248
+ const reply = await sendChatWs("ETH 当前价格是多少?", 120_000);
249
+ // Response should be human-readable, not raw JSON
250
+ // Allow either Chinese or English response
251
+ expect(reply.length).toBeGreaterThan(5);
252
+ // Should not be pure JSON (tool output should be summarized)
253
+ const isRawJson = reply.trim().startsWith("{") && reply.trim().endsWith("}");
254
+ if (isRawJson) {
255
+ // If it is JSON, it should at least be valid
256
+ expect(() => JSON.parse(reply)).not.toThrow();
257
+ }
258
+ });
259
+ });