将 src/read 从单一消费者扩展为通用资源接缝;drawer 标签页与 useCampaignReport / useFiles 统一消费;导出仪式下沉至工具函数。 - 新增 readResource.ts:ReadSlot<T> 相位机(idle/loading/ refreshing/ready/error)、requestId 竞态守卫、静默刷新、 可注入 adapter;SelectedReadSlot<T> 支持 list→detail 的 stale-response rejection。配套 characterization 测试。 - evalActivity.ts / useIntelligentEvalRead.ts 退化为 readResource 的特化;既有测试保持通过。 - DecisionProcess / ConfigSnapshots / TaskQueueMonitor 删除手写 fetch 样板(useState + try/catch + useEffect 三件套),改走 资源接缝;ExecutionProcess 的 5 个 fetch 槽同步收敛。 - 决策日志新鲜度归一:三种策略(父级 5s 详情轮询 + ExecutionProcess 独立 5s 轮询 + DecisionProcess 挂载取一次) 收敛为父级 evalActivity 单一供给,子组件消费同一份数据。 - useCampaignReport 删除私有 ReadPhase / ReadSlot 定义,改 import 自 readResource。 - ACTIVE_STATUSES 合并至 read/intelligentEval.ts 单一出口。 - 新增 utils/download.ts:downloadBlob / downloadJson 取代 api.ts 与 useFiles 中三处重复的 Blob 导出仪式(含 DOM 副作用迁出 接口定义出口)。
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import { act, renderHook } from '@testing-library/react'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
idleSlot,
|
|
isStaleResponse,
|
|
selectedSlotCleared,
|
|
selectedSlotRequested,
|
|
slotFailed,
|
|
slotRequested,
|
|
slotSucceeded,
|
|
useReadResource,
|
|
} from './readResource'
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
async function settle() {
|
|
await act(async () => { await Promise.resolve() })
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
describe('slot phase machine', () => {
|
|
const ready = { ...slotSucceeded(['a']), total: 1 }
|
|
|
|
it('silent refresh over ready data keeps the snapshot visible', () => {
|
|
expect(slotRequested(ready, true).phase).toBe('refreshing')
|
|
expect(slotRequested(ready, true).value).toEqual(['a'])
|
|
expect(slotRequested(idleSlot<string[]>([]), true).phase).toBe('loading')
|
|
})
|
|
|
|
it('failure retains a non-empty snapshot and surfaces errors otherwise', () => {
|
|
expect(slotFailed(ready, '网络错误')).toEqual({ ...ready, phase: 'ready', error: null })
|
|
expect(slotFailed(idleSlot<string[]>([]), '网络错误').phase).toBe('error')
|
|
expect(slotFailed(idleSlot<string | null>(null), '不存在').error).toBe('不存在')
|
|
})
|
|
})
|
|
|
|
describe('selected slot race guard', () => {
|
|
it('resets the value when the selection changes and drops stale responses', () => {
|
|
const first = selectedSlotRequested(selectedSlotCleared<object>(0), 'a', 1, false)
|
|
const second = selectedSlotRequested(first, 'b', 2, false)
|
|
expect(second.value).toBeNull()
|
|
expect(second.selectedId).toBe('b')
|
|
expect(isStaleResponse(second, 'a', 1)).toBe(true)
|
|
expect(isStaleResponse(second, 'b', 2)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('useReadResource', () => {
|
|
it('loads on mount, keeps the snapshot on silent failure, and polls while gated on', async () => {
|
|
vi.useFakeTimers()
|
|
const fetcher = vi.fn()
|
|
.mockResolvedValueOnce({ ok: true })
|
|
.mockRejectedValueOnce(new Error('网络错误'))
|
|
const { result } = renderHook(() => useReadResource(fetcher, { pollMs: 5000 }))
|
|
await settle()
|
|
expect(result.current.slot).toEqual({ phase: 'ready', value: { ok: true }, error: null })
|
|
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
|
await settle()
|
|
expect(fetcher).toHaveBeenCalledTimes(2)
|
|
expect(result.current.slot.phase).toBe('ready')
|
|
expect(result.current.slot.value).toEqual({ ok: true })
|
|
})
|
|
|
|
it('stops polling when the gate flips false', async () => {
|
|
vi.useFakeTimers()
|
|
const fetcher = vi.fn().mockResolvedValue('x')
|
|
const { rerender } = renderHook(
|
|
({ polling }) => useReadResource(fetcher, { pollMs: 5000, polling }),
|
|
{ initialProps: { polling: true } },
|
|
)
|
|
await settle()
|
|
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
|
|
rerender({ polling: false })
|
|
await act(async () => { await vi.advanceTimersByTimeAsync(15000) })
|
|
expect(fetcher).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('drops a stale response after the key changes', async () => {
|
|
const first = deferred<string>()
|
|
const second = deferred<string>()
|
|
const fetchers: Record<string, () => Promise<string>> = {
|
|
a: () => first.promise,
|
|
b: () => second.promise,
|
|
}
|
|
const { result, rerender } = renderHook(
|
|
({ key }) => useReadResource(() => fetchers[key](), { key }),
|
|
{ initialProps: { key: 'a' } },
|
|
)
|
|
rerender({ key: 'b' })
|
|
await act(async () => { second.resolve('B') })
|
|
expect(result.current.slot.value).toBe('B')
|
|
|
|
await act(async () => { first.resolve('A') })
|
|
expect(result.current.slot.value).toBe('B')
|
|
})
|
|
|
|
it('clears the slot when the key becomes null', async () => {
|
|
const fetcher = vi.fn().mockResolvedValue('x')
|
|
const { result, rerender } = renderHook(
|
|
({ key }) => useReadResource(fetcher, { key }),
|
|
{ initialProps: { key: 'a' as string | null } },
|
|
)
|
|
await settle()
|
|
expect(result.current.slot.phase).toBe('ready')
|
|
|
|
rerender({ key: null })
|
|
expect(result.current.slot).toEqual({ phase: 'idle', value: null, error: null })
|
|
})
|
|
})
|