Files
tick-stock-panel/frontend/src/components/SectorRotationCard.test.tsx
T
shy3130 a40c0780d7 fix(ext-data): 快照接口误拒写 + schema 类型丢失 + 分页配置静默丢弃
用户反馈资金流扩展表点「手动拉取」报错、板块轮动的资金流下拉为空。
排查出三个独立缺陷:

1. 日期一致性校验用错判据 (ext_pull.py)
   报错: 「接口返回的日期与请求日期不一致, 已拒绝写入该分区」。
   _assert_rows_date 无条件校验响应 date 必须等于请求日期, 但该契约的
   目的只是防「接口忽略 ?date= 却把当日值写进历史分区」, 仅对按日查询
   接口有意义。资金流/人气等当日快照接口只有最新一个交易日的数据,
   周末/节假日/数据源 T+1 时响应日期本就落后于请求日期, 被误判成
   「接口忽略日期参数」而整表拒写。
   修复: 新增 _assert_rows_date_if_dated, 仅在 pull.date_param 已配置
   时校验; 历史回补的 fail-closed 防污染契约不变。

2. schema 端点一直抛异常走兜底 (api/ext_data.py)
   「SELECT column_name, data_type FROM (DESCRIBE ...)」在 DuckDB 1.5
   抛 BinderException —— 该版本输出列名是 column_type (1.4 及以前才是
   data_type)。异常被吞后回退到配置字段, 那些对象只有 dtype 没有 type,
   前端按 type 判断数值列的资金流下拉因此永远为空。
   修复: 按实际输出列名取值, 两个版本都可用; 兜底分支键名统一为 type。
   同时关掉 hive 分区推断 —— date=YYYY-MM-DD 目录会让 DuckDB 凭空多出
   一个 date 列, 污染列选择器。
   前端 NUMERIC_TYPES 由写死的 5 个简写改为类型族匹配, 覆盖 DOUBLE /
   BIGINT / DECIMAL 等 Parquet 真实类型名。

3. 分页配置保存后被静默丢弃 (api/ext_data.py)
   PullConfigReq 缺少 page_param / page_size_param / page_size /
   page_start / max_pages, 前端「拉取设置」提交这些值后 Pydantic 直接
   忽略, 保存后再打开退回默认的单次请求, 且不报错。
   修复: 补齐请求模型字段并透传到 PullConfig。

验证: 三处均先写复现用例 (未修复代码上 7 个用例失败), 修复后全绿;
扩展相关后端测试 202 passed; 前端 SectorRotationCard 7 passed;
pnpm build 通过; 真实接口验证 —— 修复前 400「日期不一致」, 修复后
200 写入 5580 行, schema-all 返回 DOUBLE/BIGINT/VARCHAR 真实类型。

Signed-off-by: shy3130 <shy3130@qq.com>
2026-10-24 22:52:40 +08:00

240 lines
9.1 KiB
TypeScript

// @vitest-environment jsdom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
SectorRotationCard,
computeLeaderReigns,
selectReignLabels,
topRowPerCol,
} from './SectorRotationCard'
const chart = vi.hoisted(() => ({
on: vi.fn(), off: vi.fn(), setOption: vi.fn(), resize: vi.fn(), dispose: vi.fn(),
isDisposed: () => false, getDom: () => null, group: '',
}))
vi.mock('echarts', () => ({
init: () => chart,
getInstanceByDom: () => null,
connect: vi.fn(),
}))
vi.mock('@/lib/theme', () => ({
useChartTheme: () => ({
tooltipBg: '#101318', tooltipBorder: '#2a2f38', tooltipText: '#e5e7eb',
text: '#8b93a1', textStrong: '#d7dbe2', grid: '#1b1f27', border: '#3a4150',
}),
}))
vi.mock('@/components/Layout', () => ({
CORE_INDEXES: [{ symbol: '000001.SH', name: '上证指数' }],
}))
vi.mock('@/components/Toast', () => ({ toast: vi.fn() }))
// 三板块六桶: 低空经济领涨 col0-2, 固态电池 col3 接管; 算力租赁 col0 缺数据
const MATRIX: (number | null)[][] = [
[0.01, 0.012, 0.011, 0.013, 0.012, 0.014],
[0.005, 0.004, 0.006, 0.02, 0.022, 0.021],
[null, 0.002, 0.003, 0.004, 0.005, 0.006],
]
const BUCKETS = ['10:00', '10:05', '10:10', '10:15', '10:20', '10:25']
const SECTORS = ['低空经济', '固态电池', '算力租赁']
const apiMock = vi.hoisted(() => ({
sectorRotation: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
api: {
...apiMock,
extDataSchemaAll: vi.fn(async () => ({ items: [] })),
indexMinute: vi.fn(async () => ({ rows: [] })),
indexDaily: vi.fn(async () => ({ rows: [] })),
},
}))
// 过去日期 → 稳定走「回放」分支 (指数分钟实时接口不发请求)
function payload() {
return {
status: 'ok',
date: '2026-10-20',
as_of: '15:00',
member_count: 3,
bucket_minutes: 5,
basis: 'prev_close',
flow_available: false,
flow_field: null,
default_exclude_sectors: ['融资融券'],
max_auto_members: 300,
timeline: BUCKETS.map((time, i) => ({
time,
leader: i < 3 ? '低空经济' : '固态电池',
leader_pct: MATRIX[i < 3 ? 0 : 1][i],
market_pct: 0.002,
rotation: 0.3,
cross_up: i === 3 ? 1 : 0,
cross_down: 0,
})),
series: { sectors: SECTORS, buckets: BUCKETS, matrix: MATRIX },
sectors: SECTORS.map((name, i) => ({
name, pct_now: MATRIX[i][5], pct_prev: 0.004, rank_now: i + 1,
rank_change: i === 1 ? 2 : 0, score: 60 - i * 10,
n_members: 30, n_members_with_bars: 28, activity: 1e8, flow: null,
})),
universe: SECTORS.map(name => ({ name, activity: 1e8, pct_now: 0.01 })),
cross_events: [{ time: '10:15', name: '固态电池', dir: 'up' }],
}
}
let root: Root | null = null
let host: HTMLElement
async function renderCard(kind: 'concept' | 'industry' = 'industry') {
apiMock.sectorRotation.mockResolvedValue(payload())
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
host = document.createElement('div')
document.body.appendChild(host)
root = createRoot(host)
await act(async () => {
root!.render(
<QueryClientProvider client={client}>
<SectorRotationCard kind={kind} />
</QueryClientProvider>,
)
})
// react-query 的通知批处理需要多个微任务轮次, act 单次空 flush 不够 — 轮询等待
await vi.waitFor(() => {
expect(chart.setOption.mock.calls.length).toBeGreaterThan(0)
}, { timeout: 3000 })
}
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
afterEach(async () => {
if (root) await act(async () => { root!.unmount() })
host?.remove()
root = null
})
// ===== 纯函数 =====
it('computeLeaderReigns: 逐列 top 行变化即易主, null 跳过, 窗口开局领涨 prevRowIdx=null', () => {
expect(computeLeaderReigns(MATRIX, 0)).toEqual([
{ col: 0, rowIdx: 0, prevRowIdx: null },
{ col: 3, rowIdx: 1, prevRowIdx: 0 },
])
})
it('computeLeaderReigns: 整列无数据跳过且不误判易主', () => {
const matrix = [
[0.01, null, 0.02],
[0.005, null, 0.03],
]
// col1 整列 null → 跳过; col2 与 col0 的 top (row0) 比较后 row1 接管
expect(computeLeaderReigns(matrix, 0)).toEqual([
{ col: 0, rowIdx: 0, prevRowIdx: null },
{ col: 2, rowIdx: 1, prevRowIdx: 0 },
])
})
it('topRowPerCol: 易主区间展开为逐列 top 行', () => {
const reigns = computeLeaderReigns(MATRIX, 0)
expect(topRowPerCol(reigns, 6)).toEqual([0, 0, 0, 1, 1, 1])
})
it('selectReignLabels: 超上限时保留开局 + 最新 + 最长在位者', () => {
const matrix = [[0.01, 0.02, 0.03, 0.04, 0.05, 0.06]]
for (let row = 1; row < 5; row++) matrix.push(matrix[0].map(v => v - 0.001 * row))
// 构造 0,0,1,1,1,0 的易主序列: row1 中间在位 3 列最长
const reigns = [
{ col: 0, rowIdx: 0, prevRowIdx: null },
{ col: 2, rowIdx: 1, prevRowIdx: 0 },
{ col: 5, rowIdx: 0, prevRowIdx: 1 },
] as ReturnType<typeof computeLeaderReigns>
const picked = selectReignLabels(reigns, 6, 2)
expect(picked.has(0)).toBe(true) // 开局
expect(picked.has(5)).toBe(true) // 最新
expect(picked.size).toBe(2)
})
// ===== 渲染: 走势线模式 =====
it('trend 模式: 线末端常显板块名, 接管线上挂易主圆点标注', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
await renderCard()
const options = chart.setOption.mock.calls.map(call => call[0] as any)
const trendOption = options.find(o => o?.series?.[0]?.endLabel)
expect(trendOption).toBeTruthy()
// 3 条线 ≤8 → 全部常显末端名, 且开启 shiftY 防叠
for (const s of trendOption.series) {
expect(s.endLabel.show).toBe(true)
expect(s.endLabel.formatter).toBe(s.name)
expect(s.labelLayout.moveOverlap).toBe('shiftY')
}
// 低空经济: col0 开局领涨 1 个标注; 固态电池: col3 接管 1 个; 算力租赁: 无
expect(trendOption.series[0].markPoint.data).toEqual([
{ name: '低空经济', coord: ['10:00', 0.01] },
])
expect(trendOption.series[1].markPoint.data).toEqual([
{ name: '固态电池', coord: ['10:15', 0.02] },
])
expect(trendOption.series[2].markPoint).toBeUndefined()
// 图例条替代段落说明: 易主语义以色块图例呈现
expect(host.textContent).toContain('领涨易主')
})
// ===== 渲染: 热力图模式 =====
it('heatmap 模式: 领涨格子亮琥珀描边+辉光, 格间细缝, 中性中间色', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
localStorage.setItem('sector_rotation_display_industry', 'heatmap')
await renderCard()
const options = chart.setOption.mock.calls.map(call => call[0] as any)
const heatOption = options.find(o => o?.series?.[0]?.type === 'heatmap')
expect(heatOption).toBeTruthy()
// 领涨轨迹: row0 col0-2 + row1 col3-5 = 6 个亮琥珀描边格子 (深红格上可辨)
const trailCells = heatOption.series[0].data.filter(
(cell: any) => cell.itemStyle?.borderColor === '#FCD34D',
)
expect(trailCells).toHaveLength(6)
// 格间细缝: 系列级半透明网格线, 不再让大色块糊成一片
expect(heatOption.series[0].itemStyle.borderColor).toBe('rgba(128,140,160,0.22)')
// 配色: 中间色为可见中性, 不再用近透明的网格色
expect(heatOption.visualMap.inRange.color).toEqual(
['#14532D', '#22C55E', '#323945', '#F87171', '#B91C1C'],
)
// 图例条: 轨迹语义以真实色块表达
expect(host.textContent).toContain('领涨轨迹')
})
// ===== 资金流下拉: schema 类型名口径 =====
it('资金流下拉收录 Parquet 真实类型名 (DOUBLE/BIGINT/DECIMAL) 的数值列', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const { api } = await import('@/lib/api')
vi.mocked(api.extDataSchemaAll).mockResolvedValueOnce({
items: [{
id: 'ext_flow', label: '资金流', mode: 'timeseries', time_field: null,
columns: [
{ name: 'symbol', type: 'VARCHAR', label: '标的代码' },
// 后端 schema 端点返回的是 Parquet 真实类型, 不是配置里的 float/int 简写
{ name: 'net', type: 'DOUBLE', label: '主力净流入' },
{ name: 'rank', type: 'BIGINT', label: '排名' },
{ name: 'ratio', type: 'DECIMAL(18,4)', label: '净占比' },
{ name: 'note', type: 'VARCHAR', label: '备注' },
],
}],
} as any)
await renderCard()
// 资金流下拉在「更多」折叠区里, 先展开
const moreBtn = Array.from(host.querySelectorAll('button'))
.find(b => (b.textContent || '').includes('更多'))
await act(async () => { (moreBtn as HTMLButtonElement).click() })
const select = host.querySelector('select[aria-label="资金流扩展列"]')
expect(select).toBeTruthy()
const values = Array.from(select!.querySelectorAll('option')).map(o => (o as HTMLOptionElement).value)
expect(values).toContain('ext_flow.net')
expect(values).toContain('ext_flow.rank')
expect(values).toContain('ext_flow.ratio')
expect(values).not.toContain('ext_flow.symbol')
expect(values).not.toContain('ext_flow.note')
})