mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(signals): 数字右值条件不再整页崩溃 — 修复"扩展 official.signals 暂时不可用"
用户反馈信号库/模拟盘页显示"扩展 official.signals 暂时不可用" — 错误边界
兜底, 真实错误是渲染期 TypeError。根因: 残月策略的两个迁移信号
(canyue_long_enter/exit) 条件右值为 number (0.6/0.2, 迁移时 Python float
直接进 JSON), 后端 isinstance(int,float) 一直合法, 但前端两处
right.startsWith('field:') 假设恒为字符串, 数字右值直接抛
"startsWith is not a function", 整页渲染崩。崩点代码 v0.3.6 即有,
系数据侧新出现数字右值触发, 非本版回归。
修复: 信号库列表渲染与条件编辑器 (ConditionEditor, 多处共享) 均加
typeof 守卫, 数字常量正常显示与回填; CustomSignalCondition.right 类型
放宽为 string | number 诚实反映运行时, 编译期逼出并修复编辑器一处
隐藏收窄问题。
验证: Signals.numeric-right.test.tsx 2 条回归 (真实数据形态 + 错误边界
断言, 修复前 2 failed 复现线上错误 → 修复后 passed); 全量 vitest
337 passed; pnpm build 通过。
Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
@@ -297,9 +297,12 @@ function RightValueInput({ cond, fields, groups, maxDays, hideDays, onChangeRigh
|
||||
onChangeRight: (v: string) => void
|
||||
onChangeDays: (v: number) => void
|
||||
}) {
|
||||
const isField = cond.right.startsWith('field:')
|
||||
const fieldValue = isField ? cond.right.slice(6) : ''
|
||||
const numValue = isField ? '' : cond.right
|
||||
// 右值可为字段引用或数字常量; 迁移的历史信号 (如残月) 常量是 number — 后端
|
||||
// isinstance(int,float) 一直合法, 前端判断必须先排除非字符串再 startsWith
|
||||
const right = cond.right
|
||||
const isField = typeof right === 'string' && right.startsWith('field:')
|
||||
const fieldValue = typeof right === 'string' ? right.slice(6) : ''
|
||||
const numValue = typeof right === 'string' ? right : String(right)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
// 回归: "扩展 official.signals 暂时不可用" — 条件右值为数字 (如迁移的残月信号
|
||||
// right=0.6, 后端 isinstance(int,float) 一直合法) 时, 前端 c.right.startsWith
|
||||
// 直接 TypeError, 整页渲染崩、错误边界兜底。用真实接口数据结构 + ExtensionBoundary
|
||||
// 复刻页面挂载路径, 断言边界不再触发、条件正常显示。
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { Signals } from '@/custom/signals/extension'
|
||||
import { ConditionEditor } from '@/components/signals/ConditionEditor'
|
||||
import { ExtensionBoundary } from '@/extensions/ExtensionBoundary'
|
||||
|
||||
const fixtures = vi.hoisted(() => ({
|
||||
options: {
|
||||
fields: [
|
||||
{ key: 'close', label: '前复权收盘价' },
|
||||
{ key: 'volume', label: '成交量' },
|
||||
{ key: 'ma5', label: 'MA5' },
|
||||
{ key: 'ma20', label: 'MA20' },
|
||||
],
|
||||
groups: [{ label: '价格', fields: ['close', 'ma5', 'ma20'] }],
|
||||
maxDays: 60,
|
||||
operators: ['>', '>=', '<', '<=', '==', '!='],
|
||||
stringFields: ['symbol'],
|
||||
stringOperators: ['contains', '==', '!='],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: {
|
||||
customSignalsList: async () => ({
|
||||
signals: [
|
||||
{
|
||||
id: 'canyue_long_enter', name: '残月入场', kind: 'entry', timeframe: 'daily',
|
||||
conditions: [{ left: 'change_pct', op: '>', right: 0.6, leftDays: 0, rightDays: 0 }], // 数字右值 (真实迁移数据形态)
|
||||
column: 'signal_canyue_long_enter',
|
||||
},
|
||||
{
|
||||
id: 'ma_golden_5_20', name: '均线金叉', kind: 'both', timeframe: 'daily', enabled: true,
|
||||
conditions: [
|
||||
{ left: 'ma5', op: '>', right: 'field:ma20', leftDays: 0, rightDays: 0 },
|
||||
{ left: 'ma5', op: '>', right: 'field:ma5', leftDays: 0, rightDays: 1 },
|
||||
],
|
||||
column: 'signal_ma_golden_5_20',
|
||||
},
|
||||
],
|
||||
}),
|
||||
customSignalsOptions: async () => fixtures.options,
|
||||
strategyAiStatus: async () => ({ configured: false }),
|
||||
customSignalDelete: async () => ({ ok: true }),
|
||||
},
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLElement | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
root?.unmount()
|
||||
container?.remove()
|
||||
})
|
||||
|
||||
function render(ui: React.ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
root!.render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={['/signals']}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it('信号库页: 数字右值条件不再触发错误边界, 正常渲染', async () => {
|
||||
render(
|
||||
<ExtensionBoundary extensionId="official.signals">
|
||||
<Signals />
|
||||
</ExtensionBoundary>,
|
||||
)
|
||||
await new Promise(r => setTimeout(r, 120))
|
||||
expect(container!.textContent).not.toContain('暂时不可用')
|
||||
expect(container!.textContent).toContain('信号库')
|
||||
// 数字右值条件正常显示 (不再因 .startsWith 崩溃)
|
||||
expect(container!.textContent).toContain('0.6')
|
||||
})
|
||||
|
||||
it('条件编辑器: 数字右值不抛错, 常量输入正常挂载', async () => {
|
||||
render(
|
||||
<ConditionEditor
|
||||
conditions={[{ left: 'change_pct', op: '>', right: 0.6, leftDays: 0, rightDays: 0 }]}
|
||||
onChange={() => {}}
|
||||
options={fixtures.options as any}
|
||||
/>,
|
||||
)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
const input = container!.querySelector('input[type="number"]') as HTMLInputElement
|
||||
expect(input).not.toBeNull()
|
||||
expect(input.value).toBe('0.6')
|
||||
})
|
||||
@@ -170,7 +170,7 @@ function SignalsBody({ highlight }: { highlight: string }) {
|
||||
<span className="font-mono text-foreground/80">{fieldWithDays(c.left, c.leftDays, fields)}</span>
|
||||
<span className="font-mono text-muted">{c.op}</span>
|
||||
<span className="font-mono text-foreground/80">
|
||||
{c.right.startsWith('field:')
|
||||
{typeof c.right === 'string' && c.right.startsWith('field:')
|
||||
? fieldWithDays(c.right.slice(6), c.rightDays, fields)
|
||||
: c.right}
|
||||
</span>
|
||||
|
||||
@@ -946,7 +946,7 @@ export interface AiIterateResult {
|
||||
export interface CustomSignalCondition {
|
||||
left: string // 字段名
|
||||
op: string // > >= < <= == !=
|
||||
right: string // "field:xxx" 或数字字符串
|
||||
right: string | number // "field:xxx" 或数字常量 (迁移的历史信号常量为 number, 后端两者均合法)
|
||||
leftDays?: number // 左字段取几日前 (0=当日, 默认)
|
||||
rightDays?: number // 右字段取几日前 (仅 right 为字段时有意义)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user