import { fireEvent, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import PostSearchPage from '@/pages/posts/PostSearchPage' import { buildPost, buildTag } from '@/test/factories' import { renderWithProviders } from '@/test/render' import { CLIENT_SETTINGS_STORAGE_KEY } from '@/lib/settings' const postsApi = vi.hoisted (() => ({ fetchPosts: vi.fn (), })) const api = vi.hoisted (() => ({ apiGet: vi.fn (), })) vi.mock ('@/lib/posts', () => postsApi) vi.mock ('@/lib/api', () => api) describe ('PostSearchPage', () => { beforeEach (() => { localStorage.clear () vi.clearAllMocks () api.apiGet.mockResolvedValue ([]) }) it ('loads posts from URL search filters', async () => { postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [buildPost ({ id: 4, title: '検索対象', tags: [buildTag ({ name: '虹夏' })] })], count: 1, }) renderWithProviders ( , { route: '/posts/search?title=%E6%A4%9C%E7%B4%A2&tags=x&match=any&page=2' }, ) await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenCalledWith ( expect.objectContaining ({ title: '検索', tags: 'x', match: 'any', page: 2, limit: 20, }), ) }) expect ((await screen.findAllByRole ('link', { name: '検索対象' }))[0]).toHaveAttribute ( 'href', '/posts/4', ) }) it ('submits form state into a new search', async () => { postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) renderWithProviders (, { route: '/posts/search' }) const textboxes = screen.getAllByRole ('textbox') fireEvent.change (textboxes[0], { target: { value: 'title' } }) fireEvent.change (textboxes[1], { target: { value: 'https://example.com' } }) fireEvent.change (textboxes[2], { target: { value: 'tag' } }) fireEvent.click (screen.getByRole ('radio', { name: 'OR' })) fireEvent.click (screen.getByRole ('button', { name: '検索' })) await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( expect.objectContaining ({ title: 'title', url: 'https://example.com', tags: 'tag', match: 'any', page: 1, }), ) }) }) it ('shows the no-result message', async () => { postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) renderWithProviders (, { route: '/posts/search' }) expect (await screen.findByText ('結果ないよ(笑)')).toBeInTheDocument () }) it ('uses URL limit before localStorage and stores the active list settings', async () => { localStorage.setItem ( CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify ({ lists: { postSearch: { limit: 100, order: 'title:asc' } } })) postsApi.fetchPosts.mockResolvedValueOnce ({ posts: [], count: 0 }) renderWithProviders ( , { route: '/posts/search?limit=50&order=created_at%3Adesc' }, ) await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenCalledWith ( expect.objectContaining ({ limit: 50, order: 'created_at:desc' }), ) }) expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}')) .toMatchObject ({ lists: { postSearch: { limit: 50, order: 'created_at:desc' } } }) }) it ('moves the compact limit control outside the search form and resets page on change', async () => { postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 }) renderWithProviders (, { route: '/posts/search?page=4' }) const limitSelect = await screen.findByLabelText ('件数') expect (limitSelect.closest ('form')).toBeNull () fireEvent.change (limitSelect, { target: { value: '100' } }) await waitFor (() => { expect (postsApi.fetchPosts).toHaveBeenLastCalledWith ( expect.objectContaining ({ page: 1, limit: 100 }), ) }) }) })