mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type {
|
|
AiSearchAgentTraceItem,
|
|
AiSearchPipelineRequest,
|
|
AiSearchPipelineResult,
|
|
AiSearchProgressEvent,
|
|
AiSearchProgressStage
|
|
} from '../../src/shared/ai-search'
|
|
import { useAiSearchRun } from '../../src/renderer/src/components/search/hooks/useAiSearchRun'
|
|
import { makeSearchResult } from './support/ai-search-fixtures'
|
|
|
|
const api = {
|
|
runAiSearch: vi.fn(),
|
|
cancelAiSearch: vi.fn(),
|
|
onAiSearchProgress: vi.fn()
|
|
}
|
|
|
|
const request = (requestId: string, text = '测试问题'): AiSearchPipelineRequest => ({
|
|
requestId,
|
|
text,
|
|
scope: 'global',
|
|
range: '30d'
|
|
})
|
|
|
|
const resultFor = (
|
|
requestId: string,
|
|
status: 'completed' | 'failed' | 'ai_failed' | 'cancelled' = 'completed'
|
|
): AiSearchPipelineResult => makeSearchResult({ requestId, status })
|
|
|
|
const progressFor = (
|
|
requestId: string,
|
|
stage: AiSearchProgressStage,
|
|
trace?: AiSearchAgentTraceItem
|
|
): AiSearchProgressEvent => ({
|
|
requestId,
|
|
stage,
|
|
status: 'running',
|
|
message: `${stage} ${requestId}`,
|
|
agentTrace: trace
|
|
})
|
|
|
|
const traceFor = (sequence: number): AiSearchAgentTraceItem => ({
|
|
sequence,
|
|
event: 'agentDecision',
|
|
label: `decision-${sequence}`
|
|
})
|
|
|
|
const deferred = <T,>() => {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (error: unknown) => void
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise
|
|
reject = rejectPromise
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
let progressListener: ((event: AiSearchProgressEvent) => void) | undefined
|
|
let unsubscribe: ReturnType<typeof vi.fn>
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
progressListener = undefined
|
|
unsubscribe = vi.fn()
|
|
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
|
api.onAiSearchProgress.mockImplementation((listener) => {
|
|
progressListener = listener
|
|
return unsubscribe
|
|
})
|
|
api.cancelAiSearch.mockResolvedValue({ cancelled: true })
|
|
})
|
|
|
|
describe('useAiSearchRun', () => {
|
|
it('starts idle and registers exactly one progress listener', () => {
|
|
const { result, rerender } = renderHook(() => useAiSearchRun())
|
|
|
|
expect(result.current.status).toBe('idle')
|
|
expect(result.current.requestId).toBe('')
|
|
expect(result.current.progress).toEqual({})
|
|
expect(result.current.agentTrace).toEqual([])
|
|
rerender()
|
|
expect(api.onAiSearchProgress).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('starts a run with the exact request payload and exposes the running requestId', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
|
|
let startPromise!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
startPromise = result.current.startSearch(request('request-a', '原始问题'))
|
|
})
|
|
|
|
expect(result.current.status).toBe('running')
|
|
expect(result.current.requestId).toBe('request-a')
|
|
expect(api.runAiSearch).toHaveBeenCalledWith(request('request-a', '原始问题'))
|
|
|
|
await act(async () => {
|
|
run.resolve(resultFor('request-a'))
|
|
await startPromise
|
|
})
|
|
})
|
|
|
|
it('applies the current request result and converges to completed', async () => {
|
|
api.runAiSearch.mockResolvedValue(resultFor('request-a'))
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
|
|
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
|
await act(async () => {
|
|
outcome = await result.current.startSearch(request('request-a'))
|
|
})
|
|
|
|
expect(outcome).toMatchObject({ kind: 'completed', requestId: 'request-a' })
|
|
expect(result.current.status).toBe('completed')
|
|
expect(result.current.requestId).toBe('')
|
|
expect(result.current.result?.requestId).toBe('request-a')
|
|
})
|
|
|
|
it('converges to failed when the pipeline returns a failed status', async () => {
|
|
api.runAiSearch.mockResolvedValue(resultFor('request-failed', 'failed'))
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
|
|
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
|
await act(async () => {
|
|
outcome = await result.current.startSearch(request('request-failed'))
|
|
})
|
|
|
|
expect(outcome.kind).toBe('completed')
|
|
expect(result.current.status).toBe('failed')
|
|
expect(result.current.result?.status).toBe('failed')
|
|
})
|
|
|
|
it('converges to failed with an error when runAiSearch rejects', async () => {
|
|
api.runAiSearch.mockRejectedValue(new Error('Worker failed'))
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
|
|
let outcome!: Awaited<ReturnType<typeof result.current.startSearch>>
|
|
await act(async () => {
|
|
outcome = await result.current.startSearch(request('request-error'))
|
|
})
|
|
|
|
expect(outcome).toEqual({ kind: 'failed', requestId: 'request-error', error: 'Worker failed' })
|
|
expect(result.current.status).toBe('failed')
|
|
expect(result.current.error).toBe('Worker failed')
|
|
expect(result.current.requestId).toBe('')
|
|
})
|
|
|
|
it('applies current progress and deduplicates current Agent Trace events', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
act(() => {
|
|
void result.current.startSearch(request('request-a'))
|
|
})
|
|
|
|
act(() => progressListener?.(progressFor('request-a', 'agent_tool', traceFor(1))))
|
|
act(() => progressListener?.(progressFor('request-a', 'agent_tool', traceFor(1))))
|
|
|
|
expect(result.current.progress.agent_tool?.requestId).toBe('request-a')
|
|
expect(result.current.agentTrace).toHaveLength(1)
|
|
expect(result.current.agentTrace[0].sequence).toBe(1)
|
|
await act(async () => {
|
|
run.resolve(resultFor('request-a'))
|
|
})
|
|
})
|
|
|
|
it('ignores stale progress and Agent Trace after a newer request starts', async () => {
|
|
const runA = deferred<AiSearchPipelineResult>()
|
|
const runB = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
let promiseA!: ReturnType<typeof result.current.startSearch>
|
|
let promiseB!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
promiseA = result.current.startSearch(request('request-a'))
|
|
promiseB = result.current.startSearch(request('request-b'))
|
|
})
|
|
|
|
act(() => {
|
|
progressListener?.(progressFor('request-a', 'query_understanding', traceFor(1)))
|
|
progressListener?.(progressFor('request-b', 'agent_tool', traceFor(2)))
|
|
})
|
|
|
|
expect(result.current.progress.query_understanding).toBeUndefined()
|
|
expect(result.current.progress.agent_tool?.requestId).toBe('request-b')
|
|
expect(result.current.agentTrace.map((item) => item.sequence)).toEqual([2])
|
|
|
|
await act(async () => {
|
|
runA.resolve(resultFor('request-a'))
|
|
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
|
runB.resolve(resultFor('request-b'))
|
|
expect((await promiseB).kind).toBe('completed')
|
|
})
|
|
expect(result.current.result?.requestId).toBe('request-b')
|
|
})
|
|
|
|
it('ignores a stale result so it cannot overwrite the current request', async () => {
|
|
const runA = deferred<AiSearchPipelineResult>()
|
|
const runB = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
let promiseA!: ReturnType<typeof result.current.startSearch>
|
|
let promiseB!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
promiseA = result.current.startSearch(request('request-a'))
|
|
promiseB = result.current.startSearch(request('request-b'))
|
|
})
|
|
|
|
await act(async () => {
|
|
runA.resolve(resultFor('request-a'))
|
|
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
|
expect(result.current.result).toBeNull()
|
|
runB.resolve(resultFor('request-b'))
|
|
await promiseB
|
|
})
|
|
|
|
expect(result.current.result?.requestId).toBe('request-b')
|
|
expect(result.current.status).toBe('completed')
|
|
})
|
|
|
|
it('ignores a stale error so it cannot overwrite the current request', async () => {
|
|
const runA = deferred<AiSearchPipelineResult>()
|
|
const runB = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValueOnce(runA.promise).mockReturnValueOnce(runB.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
let promiseA!: ReturnType<typeof result.current.startSearch>
|
|
let promiseB!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
promiseA = result.current.startSearch(request('request-a'))
|
|
promiseB = result.current.startSearch(request('request-b'))
|
|
})
|
|
|
|
await act(async () => {
|
|
runA.reject(new Error('late A error'))
|
|
expect(await promiseA).toEqual({ kind: 'stale', requestId: 'request-a' })
|
|
expect(result.current.error).toBe('')
|
|
runB.resolve(resultFor('request-b'))
|
|
await promiseB
|
|
})
|
|
|
|
expect(result.current.error).toBe('')
|
|
expect(result.current.result?.requestId).toBe('request-b')
|
|
})
|
|
|
|
it('cancels the current request through the existing IPC and converges state', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
act(() => {
|
|
void result.current.startSearch(request('request-cancel'))
|
|
})
|
|
|
|
await act(async () => result.current.cancelSearch())
|
|
|
|
expect(api.cancelAiSearch).toHaveBeenCalledWith('request-cancel')
|
|
expect(result.current.status).toBe('cancelled')
|
|
expect(result.current.requestId).toBe('')
|
|
expect(result.current.progress).toEqual({})
|
|
expect(result.current.agentTrace).toEqual([])
|
|
})
|
|
|
|
it('ignores a late result after cancellation', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
let startPromise!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
startPromise = result.current.startSearch(request('request-cancel'))
|
|
})
|
|
await act(async () => result.current.cancelSearch())
|
|
|
|
await act(async () => {
|
|
run.resolve(resultFor('request-cancel'))
|
|
expect(await startPromise).toEqual({ kind: 'stale', requestId: 'request-cancel' })
|
|
})
|
|
expect(result.current.result).toBeNull()
|
|
expect(result.current.status).toBe('cancelled')
|
|
})
|
|
|
|
it('does not call cancel IPC when no request is active', async () => {
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
|
|
await act(async () => result.current.cancelSearch())
|
|
|
|
expect(api.cancelAiSearch).not.toHaveBeenCalled()
|
|
expect(result.current.status).toBe('idle')
|
|
})
|
|
|
|
it('ignores progress after the request has ended', async () => {
|
|
api.runAiSearch.mockResolvedValue(resultFor('request-ended'))
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
await act(async () => result.current.startSearch(request('request-ended')))
|
|
const progressBefore = result.current.progress
|
|
|
|
act(() => progressListener?.(progressFor('request-ended', 'error', traceFor(9))))
|
|
|
|
expect(result.current.progress).toBe(progressBefore)
|
|
expect(result.current.agentTrace).toEqual([])
|
|
})
|
|
|
|
it('resets the run state without cancelling an IPC request', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result } = renderHook(() => useAiSearchRun())
|
|
act(() => {
|
|
void result.current.startSearch(request('request-reset'))
|
|
})
|
|
act(() => result.current.resetSearchRun())
|
|
|
|
expect(result.current.status).toBe('idle')
|
|
expect(result.current.requestId).toBe('')
|
|
expect(result.current.result).toBeNull()
|
|
expect(result.current.progress).toEqual({})
|
|
expect(result.current.agentTrace).toEqual([])
|
|
expect(api.cancelAiSearch).not.toHaveBeenCalled()
|
|
await act(async () => {
|
|
run.resolve(resultFor('request-reset'))
|
|
})
|
|
})
|
|
|
|
it('cleans up the progress listener and invalidates an active request on unmount', async () => {
|
|
const run = deferred<AiSearchPipelineResult>()
|
|
api.runAiSearch.mockReturnValue(run.promise)
|
|
const { result, unmount } = renderHook(() => useAiSearchRun())
|
|
let startPromise!: ReturnType<typeof result.current.startSearch>
|
|
act(() => {
|
|
startPromise = result.current.startSearch(request('request-unmount'))
|
|
})
|
|
|
|
unmount()
|
|
expect(unsubscribe).toHaveBeenCalledOnce()
|
|
await act(async () => {
|
|
run.resolve(resultFor('request-unmount'))
|
|
expect(await startPromise).toEqual({ kind: 'stale', requestId: 'request-unmount' })
|
|
})
|
|
})
|
|
})
|