AgentEvalTool/frontend/web/src/read/readResource.test.tsx
sinohqb f458897ab5 refactor(read): 前端泛化 ReadSlot 资源接缝(Phase 4.18-21)
将 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 副作用迁出
  接口定义出口)。
2026-08-24 05:51:53 +08:00

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 })
})
})