321 lines
8.2 KiB
TypeScript
321 lines
8.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { render, screen, fireEvent, act } from '@testing-library/react'
|
|
import SearchOverlay from '@/components/search/SearchOverlay'
|
|
|
|
process.env.NEXT_PUBLIC_DIRECTUS_URL = 'https://cms.achmad.dev'
|
|
process.env.NEXT_PUBLIC_DIRECTUS_TOKEN = 'test-token'
|
|
|
|
const mockPush = vi.fn()
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: vi.fn(() => ({ push: mockPush })),
|
|
}))
|
|
|
|
const mockFetch = vi.fn()
|
|
global.fetch = mockFetch
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
vi.useFakeTimers()
|
|
mockFetch.mockResolvedValue({
|
|
json: () => Promise.resolve({ data: [] }),
|
|
})
|
|
mockPush.mockReset()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
function openSearch() {
|
|
window.dispatchEvent(new CustomEvent('kotobane:open-search'))
|
|
}
|
|
|
|
describe('SearchOverlay', () => {
|
|
it('does not render when closed initially', () => {
|
|
const { container } = render(<SearchOverlay />)
|
|
expect(container.innerHTML).toBe('')
|
|
})
|
|
|
|
it('opens when custom event is dispatched', () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
|
})
|
|
|
|
it('opens when Ctrl+K / Cmd+K is pressed', () => {
|
|
render(<SearchOverlay />)
|
|
fireEvent.keyDown(window, { key: 'k', metaKey: true })
|
|
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
|
})
|
|
|
|
it('closes when Escape is pressed', () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' })
|
|
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('closes when clicking the backdrop', () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
expect(screen.getByPlaceholderText('Search articles…')).toBeInTheDocument()
|
|
|
|
const backdrop = document.querySelector('.bg-black\\/60')
|
|
expect(backdrop).toBeInTheDocument()
|
|
if (backdrop) fireEvent.click(backdrop)
|
|
expect(screen.queryByPlaceholderText('Search articles…')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('fetches search results after debounce delay', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
{
|
|
id: '1',
|
|
title: 'Search Result',
|
|
slug: 'search-result',
|
|
category: { slug: 'anime', name: 'Anime' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'Frieren' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText('Search Result')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows category name in search results', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
{
|
|
id: '1',
|
|
title: 'Result',
|
|
slug: 'result',
|
|
category: { slug: 'music', name: 'Music' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'song' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText('Music')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows loading indicator while searching', async () => {
|
|
mockFetch.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
setTimeout(
|
|
() =>
|
|
resolve({
|
|
json: () => Promise.resolve({ data: [] }),
|
|
}),
|
|
500,
|
|
),
|
|
),
|
|
)
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'test' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
// After the debounce fires, setLoading(true) is called synchronously
|
|
expect(screen.getByText('Searching…')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows no results message when search returns empty', async () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'zzzz' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText(/no results/i)).toBeInTheDocument()
|
|
})
|
|
|
|
it('navigates to article on click', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
{
|
|
id: '1',
|
|
title: 'Clickable Result',
|
|
slug: 'clickable-result',
|
|
category: { slug: 'anime', name: 'Anime' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'item' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText('Clickable Result')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByText('Clickable Result'))
|
|
expect(mockPush).toHaveBeenCalledWith('/anime/clickable-result')
|
|
})
|
|
|
|
it('clears results when input is cleared', () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'test' } })
|
|
fireEvent.change(input, { target: { value: '' } })
|
|
|
|
expect(screen.queryByText('No results')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('debounces search requests', () => {
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'a' } })
|
|
fireEvent.change(input, { target: { value: 'ab' } })
|
|
fireEvent.change(input, { target: { value: 'abc' } })
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(100)
|
|
})
|
|
|
|
// Should not have fired yet (debounce is 300ms)
|
|
expect(mockFetch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('navigates on Enter key press', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
{
|
|
id: '1',
|
|
title: 'Enter Result',
|
|
slug: 'enter-result',
|
|
category: { slug: 'manga', name: 'Manga' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'manga' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText('Enter Result')).toBeInTheDocument()
|
|
|
|
fireEvent.keyDown(input, { key: 'Enter' })
|
|
expect(mockPush).toHaveBeenCalledWith('/manga/enter-result')
|
|
})
|
|
|
|
it('clears search results when overlay reopens via Escape', async () => {
|
|
mockFetch.mockResolvedValue({
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
{
|
|
id: '1',
|
|
title: 'Temp Result',
|
|
slug: 'temp',
|
|
category: { slug: 'anime', name: 'Anime' },
|
|
},
|
|
],
|
|
}),
|
|
})
|
|
|
|
render(<SearchOverlay />)
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
const input = screen.getByPlaceholderText('Search articles…')
|
|
fireEvent.change(input, { target: { value: 'temp' } })
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(300)
|
|
})
|
|
|
|
expect(screen.getByText('Temp Result')).toBeInTheDocument()
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape' })
|
|
|
|
act(() => {
|
|
openSearch()
|
|
})
|
|
|
|
expect(screen.queryByText('Temp Result')).not.toBeInTheDocument()
|
|
expect(screen.getByPlaceholderText('Search articles…')).toHaveValue('')
|
|
})
|
|
})
|