331 lines
9.9 KiB
TypeScript
331 lines
9.9 KiB
TypeScript
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<typeof import('../use-post-feed')>()
|
|
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(<PostFeed request={undefined} />)
|
|
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
|
|
expect(screen.getByRole('status')).toHaveTextContent(
|
|
'投稿を取得しています…',
|
|
)
|
|
})
|
|
|
|
it('renders a quiet empty state', () => {
|
|
usePostFeed.mockReturnValue({
|
|
...queryResult(),
|
|
data: { pages: [{ tweets: [] }], pageParams: [undefined] },
|
|
} as never)
|
|
|
|
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
|
|
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<never>(() => undefined))
|
|
usePostFeed.mockReturnValue({
|
|
...queryResult(),
|
|
data: {
|
|
pages: [{ tweets: [], nextCursor: 'next' }],
|
|
pageParams: [undefined],
|
|
},
|
|
fetchNextPage,
|
|
hasNextPage: true,
|
|
} as never)
|
|
|
|
render(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
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(
|
|
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
|
)
|
|
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
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(<PostFeed request={{ kind: 'user', target: 'bad' }} />)
|
|
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
|
|
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(<PostFeed request={{ kind: 'user', target: 'yuta' }} />)
|
|
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<never>(() => undefined))
|
|
usePostFeed.mockReturnValue({
|
|
...queryResult(),
|
|
data: {
|
|
pages: [{ tweets: [post('1')], nextCursor: 'next' }],
|
|
pageParams: [undefined],
|
|
},
|
|
fetchNextPage,
|
|
hasNextPage: true,
|
|
} as never)
|
|
|
|
const { unmount } = render(
|
|
<PostFeed request={{ kind: 'user', target: 'yuta' }} />,
|
|
)
|
|
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()
|
|
})
|
|
})
|