mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
Review estrutural + qualidade (sem P1): spec ganha 3 pins do contrato mais sutil (loading em voo, retry pós-falha, quirk gate→not-ready com resposta stale — comportamento herdado dos 3 originais, preservado); UseAsyncSearchOptionsResult vira tipo local (export especulativo, knip types:error); docstring documenta o quirk e o trim; plano registra o ajuste de execução.
175 lines
6.4 KiB
TypeScript
175 lines
6.4 KiB
TypeScript
import { act, renderHook } from '@testing-library/react'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { ASYNC_SEARCH_DEBOUNCE_MS, useAsyncSearchOptions } from '@/hooks/useAsyncSearchOptions'
|
|
|
|
type TestOption = { id: number; label: string }
|
|
|
|
const renderOptions = (
|
|
props: {
|
|
open?: boolean
|
|
query?: string
|
|
isQueryReady?: (query: string) => boolean
|
|
} = {},
|
|
) => {
|
|
const search = vi.fn(
|
|
async (query: string): Promise<TestOption[]> => [{ id: 1, label: `hit:${query}` }],
|
|
)
|
|
const view = renderHook(
|
|
(current: { open: boolean; query: string; isQueryReady?: (query: string) => boolean }) =>
|
|
useAsyncSearchOptions<TestOption>({
|
|
open: current.open,
|
|
query: current.query,
|
|
search,
|
|
...(current.isQueryReady ? { isQueryReady: current.isQueryReady } : {}),
|
|
}),
|
|
{
|
|
initialProps: {
|
|
open: props.open ?? true,
|
|
query: props.query ?? '',
|
|
...(props.isQueryReady ? { isQueryReady: props.isQueryReady } : {}),
|
|
},
|
|
},
|
|
)
|
|
return { search, view }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
describe('useAsyncSearchOptions', () => {
|
|
it('waits out the debounce, then searches with the trimmed query', async () => {
|
|
const { search, view } = renderOptions({ query: ' ca ' })
|
|
|
|
expect(search).not.toHaveBeenCalled()
|
|
expect(view.result.current.loading).toBe(false)
|
|
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(search).toHaveBeenCalledTimes(1)
|
|
expect(search).toHaveBeenCalledWith('ca')
|
|
expect(view.result.current.options).toEqual([{ id: 1, label: 'hit:ca' }])
|
|
expect(view.result.current.loading).toBe(false)
|
|
expect(view.result.current.failed).toBe(false)
|
|
})
|
|
|
|
it('does not search while the dialog stays closed', async () => {
|
|
const { search, view } = renderOptions({ open: false, query: 'cairu' })
|
|
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(search).not.toHaveBeenCalled()
|
|
expect(view.result.current.options).toEqual([])
|
|
expect(view.result.current.loading).toBe(false)
|
|
})
|
|
|
|
it('coalesces rapid query changes into a single search for the last query', async () => {
|
|
const { search, view } = renderOptions({ query: 'a' })
|
|
|
|
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS - 1))
|
|
view.rerender({ open: true, query: 'ab' })
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(search).toHaveBeenCalledTimes(1)
|
|
expect(search).toHaveBeenCalledWith('ab')
|
|
})
|
|
|
|
it('resets state without searching when the query is not ready', async () => {
|
|
const { search, view } = renderOptions({ isQueryReady: (query) => query.length >= 2 })
|
|
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(search).not.toHaveBeenCalled()
|
|
expect(view.result.current.options).toEqual([])
|
|
expect(view.result.current.loading).toBe(false)
|
|
expect(view.result.current.failed).toBe(false)
|
|
|
|
view.rerender({ open: true, query: 'ca', isQueryReady: (query) => query.length >= 2 })
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(search).toHaveBeenCalledTimes(1)
|
|
expect(search).toHaveBeenCalledWith('ca')
|
|
})
|
|
|
|
it('sets failed on a rejected search and clears loading', async () => {
|
|
const { search, view } = renderOptions({ query: 'ca' })
|
|
search.mockRejectedValueOnce(new Error('boom'))
|
|
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(view.result.current.failed).toBe(true)
|
|
expect(view.result.current.loading).toBe(false)
|
|
expect(view.result.current.options).toEqual([])
|
|
})
|
|
|
|
it('recovers from a failed search on the next successful one', async () => {
|
|
const { search, view } = renderOptions({ query: 'ca' })
|
|
search.mockRejectedValueOnce(new Error('boom'))
|
|
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
expect(view.result.current.failed).toBe(true)
|
|
|
|
view.rerender({ open: true, query: 'cairu' })
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(view.result.current.failed).toBe(false)
|
|
expect(view.result.current.options).toEqual([{ id: 1, label: 'hit:cairu' }])
|
|
expect(view.result.current.loading).toBe(false)
|
|
})
|
|
|
|
it('keeps loading while the request is in flight', async () => {
|
|
const { search, view } = renderOptions({ query: 'ca' })
|
|
search.mockImplementationOnce(() => new Promise<TestOption[]>(() => {}))
|
|
|
|
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
|
|
expect(view.result.current.loading).toBe(true)
|
|
expect(view.result.current.failed).toBe(false)
|
|
})
|
|
|
|
it('lets a stale in-flight response land after the query turns not-ready (preserved quirk)', async () => {
|
|
const { search, view } = renderOptions({ query: 'ca', isQueryReady: (q) => q.length >= 2 })
|
|
|
|
let resolveFirst: (options: TestOption[]) => void = () => {}
|
|
search.mockImplementationOnce(
|
|
() => new Promise<TestOption[]>((resolve) => (resolveFirst = resolve)),
|
|
)
|
|
|
|
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
expect(view.result.current.loading).toBe(true)
|
|
|
|
view.rerender({ open: true, query: '', isQueryReady: (q) => q.length >= 2 })
|
|
expect(view.result.current.options).toEqual([])
|
|
expect(view.result.current.loading).toBe(false)
|
|
|
|
await act(async () => resolveFirst([{ id: 1, label: 'stale' }]))
|
|
|
|
expect(view.result.current.options).toEqual([{ id: 1, label: 'stale' }])
|
|
expect(view.result.current.loading).toBe(false)
|
|
})
|
|
|
|
it('discards an out-of-order response from a superseded request', async () => {
|
|
const { search, view } = renderOptions({ query: 'ca' })
|
|
|
|
let resolveFirst: (options: TestOption[]) => void = () => {}
|
|
search.mockImplementationOnce(
|
|
() => new Promise<TestOption[]>((resolve) => (resolveFirst = resolve)),
|
|
)
|
|
search.mockResolvedValueOnce([{ id: 2, label: 'hit:campanha' }])
|
|
|
|
act(() => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
view.rerender({ open: true, query: 'campanha' })
|
|
await act(async () => void vi.advanceTimersByTime(ASYNC_SEARCH_DEBOUNCE_MS))
|
|
expect(search).toHaveBeenCalledTimes(2)
|
|
|
|
await act(async () => resolveFirst([{ id: 1, label: 'stale' }]))
|
|
|
|
expect(view.result.current.options).toEqual([{ id: 2, label: 'hit:campanha' }])
|
|
expect(view.result.current.loading).toBe(false)
|
|
})
|
|
})
|