import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Post } from '../types' import { PostLoadError } from '../use-post-feed' import { PostFeed } from './post-feed' const usePostFeed = vi.hoisted(() => vi.fn()) vi.mock('../use-post-feed', async (importOriginal) => { const original = await importOriginal() return { ...original, usePostFeed } }) const observe = vi.fn() const unobserve = vi.fn() const disconnect = vi.fn() let observerCallback: IntersectionObserverCallback = () => {} let observerOptions: IntersectionObserverInit | undefined class FakeIntersectionObserver { readonly root = null readonly rootMargin: string readonly thresholds: readonly number[] = [] constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ) { observerCallback = callback observerOptions = options this.rootMargin = options?.rootMargin ?? '0px' } disconnect = disconnect observe = observe takeRecords = () => [] unobserve = unobserve } const post = (id: string): Post => ({ id, text: `post-${id}`, author: { username: `user-${id}`, name: `User ${id}` }, }) const queryResult = () => ({ data: undefined, error: null, fetchNextPage: vi.fn().mockResolvedValue(undefined), hasNextPage: false, isError: false, isFetchNextPageError: false, isFetchingNextPage: false, isPending: false, refetch: vi.fn().mockResolvedValue(undefined), }) describe('PostFeed', () => { beforeEach(() => { usePostFeed.mockReset() usePostFeed.mockReturnValue(queryResult() as never) observe.mockReset() unobserve.mockReset() disconnect.mockReset() observerCallback = () => {} observerOptions = undefined vi.stubGlobal( 'IntersectionObserver', FakeIntersectionObserver as unknown as typeof IntersectionObserver, ) }) afterEach(() => vi.unstubAllGlobals()) it('renders no feed state before a deliberate request', () => { render() expect(usePostFeed).toHaveBeenCalledWith(undefined) expect(screen.queryByRole('article')).not.toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('announces the initial loading state', () => { usePostFeed.mockReturnValue({ ...queryResult(), isPending: true, } as never) render() expect(screen.getByRole('status')).toHaveTextContent( '投稿を取得しています…', ) }) it('renders a quiet empty state', () => { usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [] }], pageParams: [undefined] }, } as never) render() expect(screen.getByText('条件に一致する投稿はありません。')).toBeVisible() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('continues from an empty page when a next cursor exists', () => { const fetchNextPage = vi.fn(() => new Promise(() => undefined)) usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [], nextCursor: 'next' }], pageParams: [undefined], }, fetchNextPage, hasNextPage: true, } as never) render() act(() => { observerCallback( [{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver, ) }) expect( screen.queryByText('条件に一致する投稿はありません。'), ).not.toBeInTheDocument() expect(fetchNextPage).toHaveBeenCalledOnce() }) it('deduplicates every page while preserving a quiet end state', () => { usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [ { tweets: [post('1'), post('2')], nextCursor: 'next' }, { tweets: [post('2'), post('3')] }, ], pageParams: [undefined, 'next'], }, } as never) const { container } = render( , ) expect(screen.getAllByRole('article')).toHaveLength(3) expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument() expect(screen.getByText('これ以上の投稿はありません。')).toBeVisible() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('retries an initial retryable error with refetch', () => { const refetch = vi.fn().mockResolvedValue(undefined) usePostFeed.mockReturnValue({ ...queryResult(), error: new PostLoadError({ code: 'upstream', message: '最初の取得に失敗しました。', retryable: true, }), isError: true, refetch, } as never) render() fireEvent.click(screen.getByRole('button', { name: '再試行' })) expect(screen.getByRole('alert')).toHaveTextContent( '最初の取得に失敗しました。', ) expect(refetch).toHaveBeenCalledOnce() }) it('does not offer retry for a non-retryable initial error', () => { usePostFeed.mockReturnValue({ ...queryResult(), error: new PostLoadError({ code: 'invalid-input', message: '入力を確認してください。', retryable: false, }), isError: true, } as never) render() expect(screen.getByRole('alert')).toHaveTextContent( '入力を確認してください。', ) expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('keeps loaded posts while retrying a later page', () => { const fetchNextPage = vi.fn().mockResolvedValue(undefined) usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [post('kept')], nextCursor: 'next' }], pageParams: [undefined], }, error: new PostLoadError({ code: 'timeout', message: '取得がタイムアウトしました。', retryable: true, }), fetchNextPage, hasNextPage: true, isError: true, isFetchNextPageError: true, } as never) render() fireEvent.click(screen.getByRole('button', { name: '再試行' })) expect(screen.getByText('post-kept')).toBeVisible() expect(screen.getByRole('alert')).toHaveTextContent( '続きの投稿を取得できませんでした。', ) expect(observe).not.toHaveBeenCalled() expect(fetchNextPage).toHaveBeenCalledOnce() }) it('retries a later page after an empty page with fetchNextPage', () => { const fetchNextPage = vi.fn().mockResolvedValue(undefined) const refetch = vi.fn().mockResolvedValue(undefined) usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [], nextCursor: 'next' }], pageParams: [undefined], }, error: new PostLoadError({ code: 'timeout', message: '取得がタイムアウトしました。', retryable: true, }), fetchNextPage, hasNextPage: true, isError: true, isFetchNextPageError: true, refetch, } as never) render() fireEvent.click(screen.getByRole('button', { name: '再試行' })) expect(screen.getByRole('alert')).toHaveTextContent( '続きの投稿を取得できませんでした。', ) expect(fetchNextPage).toHaveBeenCalledOnce() expect(refetch).not.toHaveBeenCalled() }) it('announces next-page loading', () => { usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [post('1')], nextCursor: 'next' }], pageParams: [undefined], }, hasNextPage: true, isFetchingNextPage: true, } as never) render() expect(screen.getByRole('status')).toHaveTextContent( '次の投稿を取得しています…', ) }) it('does not auto-fetch while a next page is already loading', () => { const fetchNextPage = vi.fn().mockResolvedValue(undefined) usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [post('1')], nextCursor: 'next' }], pageParams: [undefined], }, fetchNextPage, hasNextPage: true, isFetchingNextPage: true, } as never) render() act(() => { observerCallback( [{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver, ) }) expect(observe).not.toHaveBeenCalled() expect(fetchNextPage).not.toHaveBeenCalled() }) it('auto-fetches once near the sentinel and disconnects on cleanup', () => { const fetchNextPage = vi.fn(() => new Promise(() => undefined)) usePostFeed.mockReturnValue({ ...queryResult(), data: { pages: [{ tweets: [post('1')], nextCursor: 'next' }], pageParams: [undefined], }, fetchNextPage, hasNextPage: true, } as never) const { unmount } = render( , ) act(() => { const entries = [{ isIntersecting: true } as IntersectionObserverEntry] observerCallback(entries, {} as IntersectionObserver) observerCallback(entries, {} as IntersectionObserver) }) expect(observerOptions).toEqual({ rootMargin: '600px 0px' }) expect(observe).toHaveBeenCalledOnce() expect(fetchNextPage).toHaveBeenCalledOnce() unmount() expect(disconnect).toHaveBeenCalledOnce() }) })