mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
fix(data): busy 降级期间 storage 为 null 不再使数据页整页崩溃
用户反馈桌面客户端数据页 "Unexpected Application Error! Cannot read
properties of null (reading 'total_size_mb')"。根因: /api/data/status 在
预热/同步/修复期间 (busy) 不重算存储统计, 冷启动缓存从未热过时
_get_storage 返回 None — 前端存储卡片 s.storage.total_size_mb 只判了 s
没判 s.storage, 6 处访问全部抛 TypeError, 整页被错误边界接管。与此前
"立即获取数据"误判同族 (busy 降级契约: 空值不代表无数据)。
- DataStatus.storage 类型改为 {...} | null 诚实反映降级契约, 编译期
强制所有消费点判空
- 数据页取安全别名 const storage = s?.storage, 总大小与表统计/扩展数据
全部 storage?. 判空, 缺值降级显示 — 或 0
- 后端补契约测试: 冷启动 busy 且缓存未热时 _get_storage 返回 None
(原测试只覆盖缓存热过后清空仍留 stale 的路径)
验证: Data.status.test 修复前红 (错误与用户截图逐字一致) 修复后绿;
后端 test_data_status_busy 10 passed; 全量 vitest 338 passed; pnpm build。
Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
@@ -76,6 +76,16 @@ def test_get_storage_busy_serves_stale(clean_caches, tmp_path):
|
||||
assert got is not None # stale 兜底仍在
|
||||
|
||||
|
||||
def test_get_storage_busy_cold_start_returns_none(clean_caches, tmp_path):
|
||||
"""服务刚启动、storage 缓存从未热过时, busy 期间返回 None (契约)。
|
||||
|
||||
/api/data/status 的消费方 (如数据页存储卡片) 必须按 null 降级 —
|
||||
直接 s.storage.total_size_mb 会在预热/同步期间抛 TypeError
|
||||
(v0.3.22 修复 "Cannot read properties of null (reading 'total_size_mb')" 的依据)。
|
||||
"""
|
||||
assert data_api._get_storage(tmp_path, busy=True) is None
|
||||
|
||||
|
||||
# ---------- busy 判定 ----------
|
||||
|
||||
def _req(indicators_ready=True):
|
||||
|
||||
@@ -4920,7 +4920,7 @@ export interface DataStatus {
|
||||
ext_data_files?: number
|
||||
ext_data_size_mb?: number
|
||||
total_size_mb: number
|
||||
}
|
||||
} | null // busy 降级 (预热/同步/修复期间) 且冷启动缓存未热时为 null, 不代表无数据
|
||||
next_pipeline_run: string | null
|
||||
next_instruments_run: string | null
|
||||
last_pipeline_run: string | null
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
// 回归: busy 降级期间 storage 为 null 时数据页整页崩溃
|
||||
// (用户反馈桌面客户端 "Unexpected Application Error! Cannot read properties of
|
||||
// null (reading 'total_size_mb')" — 后端 /api/data/status 在预热/同步/修复期间
|
||||
// 返回 storage=null, 前端 s.storage.xxx 未判空直接抛 TypeError)。
|
||||
import { act } from 'react'
|
||||
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 { Data } from './Data'
|
||||
import type { DataStatus } from '@/lib/api'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: {
|
||||
dataStatus: vi.fn(),
|
||||
regimeCoverage: vi.fn(async () => ({ rows: 0, earliest_date: null, latest_date: null })),
|
||||
pipelineJobs: vi.fn(async () => ({ active_id: null, jobs: [] })),
|
||||
pipelineJob: vi.fn(async () => null),
|
||||
extDataList: vi.fn(async () => ({ items: [] })),
|
||||
dataSources: vi.fn(async () => []),
|
||||
settings: vi.fn(async () => ({ onboarding_completed: true, mode: 'api_key', has_tickflow_key: true })),
|
||||
preferences: vi.fn(async () => ({})),
|
||||
capabilities: vi.fn(async () => ({ capabilities: {} })),
|
||||
} }))
|
||||
vi.mock('@/components/StockPreviewDialog', () => ({ StockPreviewDialog: () => null }))
|
||||
vi.mock('@/components/EndpointTestDialog', () => ({ EndpointTestDialog: () => null }))
|
||||
|
||||
let host: HTMLDivElement
|
||||
let root: Root
|
||||
let client: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
sessionStorage.clear()
|
||||
host = document.createElement('div')
|
||||
document.body.append(host)
|
||||
root = createRoot(host)
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
client.clear()
|
||||
host.remove()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
// busy 降级形态: status 非空但各统计与 storage 均为 null (冷启动缓存未热)
|
||||
const busyStatus = {
|
||||
indicators_ready: false,
|
||||
stats_pending: true,
|
||||
daily: null,
|
||||
enriched: null,
|
||||
index_daily: null,
|
||||
index_enriched: null,
|
||||
etf_daily: null,
|
||||
etf_enriched: null,
|
||||
minute: null,
|
||||
adj_factor: null,
|
||||
instruments: null,
|
||||
financials: null,
|
||||
storage: null,
|
||||
next_pipeline_run: null,
|
||||
next_instruments_run: null,
|
||||
last_pipeline_run: null,
|
||||
last_instruments_run: null,
|
||||
checked_at: new Date().toISOString(),
|
||||
} as unknown as DataStatus
|
||||
|
||||
async function renderPage() {
|
||||
const errors: unknown[] = []
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args) })
|
||||
await act(async () => root.render(
|
||||
<MemoryRouter><QueryClientProvider client={client}><Data /></QueryClientProvider></MemoryRouter>,
|
||||
))
|
||||
await act(async () => { await new Promise(r => setTimeout(r, 60)) })
|
||||
spy.mockRestore()
|
||||
return errors
|
||||
}
|
||||
|
||||
it('storage 为 null 时不崩, 存储卡片降级为占位', async () => {
|
||||
const { api } = await import('@/lib/api')
|
||||
vi.mocked(api.dataStatus).mockResolvedValue(busyStatus)
|
||||
|
||||
const errors = await renderPage()
|
||||
// 渲染期 TypeError 会被 React 记为 console.error("The above error occurred ...")
|
||||
const crashed = errors.some(e => String(e).includes('total_size_mb'))
|
||||
expect(crashed).toBe(false)
|
||||
expect(host.textContent).toContain('存储')
|
||||
expect(host.textContent).toContain('—') // 总大小降级占位
|
||||
// 各 storage 数值区降级显示, 不出现 NaN/undefined
|
||||
expect(host.textContent).not.toContain('undefined')
|
||||
})
|
||||
@@ -319,6 +319,9 @@ export function Data() {
|
||||
}, [history.data?.active_id])
|
||||
|
||||
const s = status.data
|
||||
// busy 降级 (预热/同步/修复期间) 且冷启动缓存未热时 storage 为 null — 单独取别名,
|
||||
// 各消费点用 storage?. 判空 (直接 s.storage.x 会在 status 非空时仍抛 TypeError)
|
||||
const storage = s?.storage
|
||||
const isLoading = status.isLoading
|
||||
const isRunning = job.data?.status === 'running' || job.data?.status === 'pending'
|
||||
const isStarting = startSync.isPending
|
||||
@@ -888,7 +891,7 @@ export function Data() {
|
||||
{isLoading ? (
|
||||
<Skeleton w="w-12" />
|
||||
) : (
|
||||
<span className="font-mono text-xs text-muted">{s ? `${s.storage.total_size_mb} MB` : '—'}</span>
|
||||
<span className="font-mono text-xs text-muted">{storage ? `${storage.total_size_mb} MB` : '—'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -903,12 +906,12 @@ export function Data() {
|
||||
</div>
|
||||
))
|
||||
) : [
|
||||
{ label: '个股维表', files: s?.storage.instruments_files, size: s?.storage.instruments_size_mb },
|
||||
{ label: '日 K', files: s?.storage.daily_files, size: s?.storage.daily_size_mb },
|
||||
{ label: '除权因子', files: s?.storage.adj_factor_files, size: s?.storage.adj_factor_size_mb },
|
||||
{ label: 'Enriched', files: s?.storage.enriched_files, size: s?.storage.enriched_size_mb },
|
||||
{ label: '分钟 K', files: s?.storage.minute_files, size: s?.storage.minute_size_mb },
|
||||
{ label: '财务数据', files: s?.storage.financials_files, size: s?.storage.financials_size_mb },
|
||||
{ label: '个股维表', files: storage?.instruments_files, size: storage?.instruments_size_mb },
|
||||
{ label: '日 K', files: storage?.daily_files, size: storage?.daily_size_mb },
|
||||
{ label: '除权因子', files: storage?.adj_factor_files, size: storage?.adj_factor_size_mb },
|
||||
{ label: 'Enriched', files: storage?.enriched_files, size: storage?.enriched_size_mb },
|
||||
{ label: '分钟 K', files: storage?.minute_files, size: storage?.minute_size_mb },
|
||||
{ label: '财务数据', files: storage?.financials_files, size: storage?.financials_size_mb },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-muted">{item.label}</span>
|
||||
@@ -923,9 +926,9 @@ export function Data() {
|
||||
<div className="flex items-center justify-between text-[11px] border-t border-border/50 pt-2 mt-1">
|
||||
<span className="text-muted">扩展数据</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-secondary">{s?.storage.ext_data_files ?? extConfigs.data.items.length} 文件</span>
|
||||
<span className="font-mono text-secondary">{storage?.ext_data_files ?? extConfigs.data.items.length} 文件</span>
|
||||
<span className="font-mono text-muted w-16 text-right">
|
||||
{s?.storage.ext_data_size_mb != null ? `${s.storage.ext_data_size_mb.toFixed(1)} MB` : '—'}
|
||||
{storage?.ext_data_size_mb != null ? `${storage.ext_data_size_mb.toFixed(1)} MB` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user