test(web): add tests for snippets

This commit is contained in:
JzoNg
2026-03-26 21:38:22 +08:00
parent 22b382527f
commit 515036e758
6 changed files with 610 additions and 0 deletions

View File

@ -0,0 +1,161 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Snippets from '../index'
const mockUseInfiniteSnippetList = vi.fn()
const mockHandleInsertSnippet = vi.fn()
const mockHandleCreateSnippet = vi.fn()
const mockHandleOpenCreateSnippetDialog = vi.fn()
const mockHandleCloseCreateSnippetDialog = vi.fn()
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
...actual,
useInfiniteScroll: vi.fn(),
}
})
vi.mock('@/service/use-snippets', () => ({
useInfiniteSnippetList: (...args: unknown[]) => mockUseInfiniteSnippetList(...args),
}))
vi.mock('../use-insert-snippet', () => ({
useInsertSnippet: () => ({
handleInsertSnippet: mockHandleInsertSnippet,
}),
}))
vi.mock('../use-create-snippet', () => ({
useCreateSnippet: () => ({
createSnippetMutation: { isPending: false },
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
handleCreateSnippet: mockHandleCreateSnippet,
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
isCreateSnippetDialogOpen: false,
isCreatingSnippet: false,
}),
}))
vi.mock('../../../create-snippet-dialog', () => ({
default: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="create-snippet-dialog" /> : null,
}))
describe('Snippets', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseInfiniteSnippetList.mockReturnValue({
data: undefined,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
})
describe('Rendering', () => {
it('should render loading skeleton when loading', () => {
const { container } = render(<Snippets loading searchText="" />)
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
})
it('should render empty state when snippet list is empty', () => {
render(<Snippets searchText="" />)
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should render snippet rows from infinite list data', () => {
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [{
data: [{
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
input_fields: [],
created_at: 1,
updated_at: 2,
}],
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
render(<Snippets searchText="customer" />)
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
page: 1,
limit: 30,
keyword: 'customer',
is_published: true,
})
expect(screen.getByText('Customer Review')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should delegate create action from empty state', () => {
render(<Snippets searchText="" />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1)
})
it('should delegate insert action when snippet item is clicked', () => {
mockUseInfiniteSnippetList.mockReturnValue({
data: {
pages: [{
data: [{
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
input_fields: [],
created_at: 1,
updated_at: 2,
}],
}],
},
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})
render(<Snippets searchText="" />)
fireEvent.click(screen.getByText('Customer Review'))
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
})
})
})

View File

@ -0,0 +1,64 @@
import type { PublishedSnippetListItem } from '../snippet-detail-card'
import { render, screen } from '@testing-library/react'
import SnippetDetailCard from '../snippet-detail-card'
const mockUseSnippetPublishedWorkflow = vi.fn()
vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
created_at: 1,
updated_at: 2,
...overrides,
})
describe('SnippetDetailCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
})
describe('Rendering', () => {
it('should render snippet summary information', () => {
render(<SnippetDetailCard snippet={createSnippet()} />)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.getByText('Snippet description')).toBeInTheDocument()
expect(screen.getByText('Evan')).toBeInTheDocument()
})
it('should render unique block icons from published workflow graph', () => {
mockUseSnippetPublishedWorkflow.mockReturnValue({
data: {
graph: {
nodes: [
{ data: { type: 'llm' } },
{ data: { type: 'code' } },
{ data: { type: 'llm' } },
{ data: { type: 'unknown' } },
],
},
},
})
const { container } = render(<SnippetDetailCard snippet={createSnippet()} />)
expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetEmptyState from '../snippet-empty-state'
describe('SnippetEmptyState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render empty state copy and create action', () => {
const handleCreate = vi.fn()
render(<SnippetEmptyState onCreate={handleCreate} />)
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' })).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCreate when create button is clicked', () => {
const handleCreate = vi.fn()
render(<SnippetEmptyState onCreate={handleCreate} />)
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
expect(handleCreate).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,85 @@
import type { PublishedSnippetListItem } from '../snippet-detail-card'
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetListItem from '../snippet-list-item'
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
icon_info: {
icon_type: 'emoji',
icon: '🧾',
icon_background: '#FFEAD5',
icon_url: '',
},
created_at: 1,
updated_at: 2,
...overrides,
})
describe('SnippetListItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render snippet name', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.queryByText('Evan')).not.toBeInTheDocument()
})
it('should render author when hovered', () => {
render(
<SnippetListItem
snippet={createSnippet()}
isHovered
onMouseEnter={vi.fn()}
onMouseLeave={vi.fn()}
/>,
)
expect(screen.getByText('Evan')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should forward click and hover handlers', () => {
const handleClick = vi.fn()
const handleMouseEnter = vi.fn()
const handleMouseLeave = vi.fn()
render(
<SnippetListItem
snippet={createSnippet()}
isHovered={false}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>,
)
const item = screen.getByText('Customer Review').closest('div')!
fireEvent.mouseEnter(item)
fireEvent.mouseLeave(item)
fireEvent.click(item)
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
expect(handleMouseLeave).toHaveBeenCalledTimes(1)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,139 @@
import { act, renderHook } from '@testing-library/react'
import { useCreateSnippet } from '../use-create-snippet'
const mockPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockSyncDraftWorkflow = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/service/use-snippets', () => ({
useCreateSnippetMutation: () => ({
mutateAsync: mockMutateAsync,
isPending: false,
}),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
snippets: {
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
},
},
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
describe('useCreateSnippet', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('State', () => {
it('should open and close create snippet dialog', () => {
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(true)
act(() => {
result.current.handleCloseCreateSnippetDialog()
})
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
})
})
describe('Create Flow', () => {
it('should create snippet, sync draft workflow, and navigate on success', async () => {
mockMutateAsync.mockResolvedValue({ id: 'snippet-123' })
mockSyncDraftWorkflow.mockResolvedValue(undefined)
const { result } = renderHook(() => useCreateSnippet())
act(() => {
result.current.handleOpenCreateSnippetDialog()
})
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: 'desc',
icon: {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockMutateAsync).toHaveBeenCalledWith({
body: {
name: 'My snippet',
description: 'desc',
icon_info: {
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: undefined,
},
},
})
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
params: { snippetId: 'snippet-123' },
body: {
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
},
})
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
expect(result.current.isCreateSnippetDialogOpen).toBe(false)
expect(result.current.isCreatingSnippet).toBe(false)
})
it('should show error toast when create fails', async () => {
mockMutateAsync.mockRejectedValue(new Error('create failed'))
const { result } = renderHook(() => useCreateSnippet())
await act(async () => {
await result.current.handleCreateSnippet({
name: 'My snippet',
description: '',
icon: {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})
})
expect(mockToastError).toHaveBeenCalledWith('create failed')
expect(result.current.isCreatingSnippet).toBe(false)
})
})
})

View File

@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react'
import { useInsertSnippet } from '../use-insert-snippet'
const mockFetchQuery = vi.fn()
const mockHandleSyncWorkflowDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockToastError = vi.fn()
const mockGetNodes = vi.fn()
const mockSetNodes = vi.fn()
const mockSetEdges = vi.fn()
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
fetchQuery: mockFetchQuery,
}),
}))
vi.mock('reactflow', () => ({
useStoreApi: () => ({
getState: () => ({
getNodes: mockGetNodes,
setNodes: mockSetNodes,
edges: [{ id: 'existing-edge', source: 'old', target: 'old-2' }],
setEdges: mockSetEdges,
}),
}),
}))
vi.mock('../../../hooks', () => ({
useNodesSyncDraft: () => ({
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
}),
useWorkflowHistory: () => ({
saveStateToHistory: mockSaveStateToHistory,
}),
WorkflowHistoryEvent: {
NodePaste: 'NodePaste',
},
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
describe('useInsertSnippet', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetNodes.mockReturnValue([
{
id: 'existing-node',
position: { x: 0, y: 0 },
data: { selected: true },
},
])
})
describe('Insert Flow', () => {
it('should append remapped snippet graph into current workflow graph', async () => {
mockFetchQuery.mockResolvedValue({
graph: {
nodes: [
{
id: 'snippet-node-1',
position: { x: 10, y: 20 },
data: { selected: false, _children: [{ nodeId: 'snippet-node-2', nodeType: 'code' }] },
},
{
id: 'snippet-node-2',
parentId: 'snippet-node-1',
position: { x: 30, y: 40 },
data: { selected: false },
},
],
edges: [
{
id: 'edge-1',
source: 'snippet-node-1',
sourceHandle: 'source',
target: 'snippet-node-2',
targetHandle: 'target',
data: {},
},
],
},
})
const { result } = renderHook(() => useInsertSnippet())
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockFetchQuery).toHaveBeenCalledTimes(1)
expect(mockSetNodes).toHaveBeenCalledTimes(1)
expect(mockSetEdges).toHaveBeenCalledTimes(1)
const nextNodes = mockSetNodes.mock.calls[0][0]
expect(nextNodes[0].selected).toBe(false)
expect(nextNodes[0].data.selected).toBe(false)
expect(nextNodes).toHaveLength(3)
expect(nextNodes[1].id).not.toBe('snippet-node-1')
expect(nextNodes[2].parentId).toBe(nextNodes[1].id)
expect(nextNodes[1].data._children[0].nodeId).toBe(nextNodes[2].id)
const nextEdges = mockSetEdges.mock.calls[0][0]
expect(nextEdges).toHaveLength(2)
expect(nextEdges[1].source).toBe(nextNodes[1].id)
expect(nextEdges[1].target).toBe(nextNodes[2].id)
expect(mockSaveStateToHistory).toHaveBeenCalledWith('NodePaste', {
nodeId: nextNodes[1].id,
})
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
})
it('should show error toast when fetching snippet workflow fails', async () => {
mockFetchQuery.mockRejectedValue(new Error('insert failed'))
const { result } = renderHook(() => useInsertSnippet())
await act(async () => {
await result.current.handleInsertSnippet('snippet-1')
})
expect(mockToastError).toHaveBeenCalledWith('insert failed')
})
})
})