mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 22:18:15 +08:00
Co-authored-by: CodingOnStar <hanxujiang@dify.ai> Co-authored-by: CodingOnStar <hanxujiang@dify.com>
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { DataSourceType } from '@/models/datasets'
|
|
import Operations from './operations'
|
|
|
|
// Mock services
|
|
vi.mock('@/service/knowledge/use-document', () => ({
|
|
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
|
|
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
|
|
}))
|
|
|
|
// Mock utils
|
|
vi.mock('@/utils/download', () => ({
|
|
downloadUrl: vi.fn(),
|
|
}))
|
|
|
|
// Mock router
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({
|
|
push: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
describe('Operations', () => {
|
|
const defaultDetail = {
|
|
name: 'Test Document',
|
|
enabled: true,
|
|
archived: false,
|
|
id: 'doc-123',
|
|
data_source_type: DataSourceType.FILE,
|
|
doc_form: 'text',
|
|
display_status: 'available',
|
|
}
|
|
|
|
const defaultProps = {
|
|
embeddingAvailable: true,
|
|
detail: defaultDetail,
|
|
datasetId: 'dataset-456',
|
|
onUpdate: vi.fn(),
|
|
scene: 'list' as const,
|
|
className: '',
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render without crashing', () => {
|
|
render(<Operations {...defaultProps} />)
|
|
// Should render at least the container
|
|
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render switch in list scene', () => {
|
|
const { container } = render(<Operations {...defaultProps} scene="list" />)
|
|
// Switch component should be rendered
|
|
const switchEl = container.querySelector('[role="switch"]')
|
|
expect(switchEl).toBeInTheDocument()
|
|
})
|
|
|
|
it('should render settings button when embedding is available', () => {
|
|
const { container } = render(<Operations {...defaultProps} />)
|
|
// Settings button has RiEqualizer2Line icon inside
|
|
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
|
|
expect(settingsButton).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Switch Behavior', () => {
|
|
it('should render enabled switch when document is enabled', () => {
|
|
const { container } = render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, enabled: true, archived: false }}
|
|
/>,
|
|
)
|
|
const switchEl = container.querySelector('[role="switch"]')
|
|
expect(switchEl).toHaveAttribute('aria-checked', 'true')
|
|
})
|
|
|
|
it('should render disabled switch when document is disabled', () => {
|
|
const { container } = render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, enabled: false, archived: false }}
|
|
/>,
|
|
)
|
|
const switchEl = container.querySelector('[role="switch"]')
|
|
expect(switchEl).toHaveAttribute('aria-checked', 'false')
|
|
})
|
|
|
|
it('should show tooltip and disable switch when document is archived', () => {
|
|
const { container } = render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, archived: true }}
|
|
/>,
|
|
)
|
|
const switchEl = container.querySelector('[role="switch"]')
|
|
// Archived documents have visually disabled switch (CSS-based)
|
|
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
|
})
|
|
})
|
|
|
|
describe('Embedding Not Available', () => {
|
|
it('should show disabled switch when embedding not available in list scene', () => {
|
|
const { container } = render(
|
|
<Operations
|
|
{...defaultProps}
|
|
embeddingAvailable={false}
|
|
scene="list"
|
|
/>,
|
|
)
|
|
const switchEl = container.querySelector('[role="switch"]')
|
|
// Switch is visually disabled (CSS-based)
|
|
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
|
})
|
|
|
|
it('should not show settings or popover when embedding not available', () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
embeddingAvailable={false}
|
|
/>,
|
|
)
|
|
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('More Actions Popover', () => {
|
|
it('should show rename option for non-archived documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, archived: false }}
|
|
/>,
|
|
)
|
|
|
|
// Click on the more actions button
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
expect(moreButton).toBeInTheDocument()
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show download option for FILE type documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show sync option for notion documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show sync option for web documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show archive option for non-archived documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, archived: false }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show unarchive option for archived documents', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, archived: true }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show delete option', async () => {
|
|
render(<Operations {...defaultProps} />)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show pause option when status is indexing', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('should show resume option when status is paused', async () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
|
|
/>,
|
|
)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Delete Confirmation Modal', () => {
|
|
it('should show delete confirmation modal when delete is clicked', async () => {
|
|
render(<Operations {...defaultProps} />)
|
|
|
|
const moreButton = document.querySelector('[class*="commonIcon"]')
|
|
if (moreButton)
|
|
fireEvent.click(moreButton)
|
|
|
|
await waitFor(() => {
|
|
const deleteButton = screen.getByText(/list\.action\.delete/i)
|
|
fireEvent.click(deleteButton)
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
|
|
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Scene Variations', () => {
|
|
it('should render correctly in detail scene', () => {
|
|
render(<Operations {...defaultProps} scene="detail" />)
|
|
// Settings button should still be visible
|
|
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('should apply different styles in detail scene', () => {
|
|
const { container } = render(<Operations {...defaultProps} scene="detail" />)
|
|
// The component should render without the list-specific styles
|
|
expect(container.firstChild).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle undefined detail properties', () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
detail={{
|
|
name: '',
|
|
enabled: false,
|
|
archived: false,
|
|
id: '',
|
|
data_source_type: '',
|
|
doc_form: '',
|
|
display_status: undefined,
|
|
}}
|
|
/>,
|
|
)
|
|
// Should not crash
|
|
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
|
})
|
|
|
|
it('should stop event propagation on click', () => {
|
|
const parentHandler = vi.fn()
|
|
render(
|
|
<div onClick={parentHandler}>
|
|
<Operations {...defaultProps} />
|
|
</div>,
|
|
)
|
|
|
|
const container = document.querySelector('.flex.items-center')
|
|
if (container)
|
|
fireEvent.click(container)
|
|
|
|
// Parent handler should not be called due to stopPropagation
|
|
expect(parentHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle custom className', () => {
|
|
render(<Operations {...defaultProps} className="custom-class" />)
|
|
// Component should render with the custom class
|
|
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('Selected IDs Handling', () => {
|
|
it('should pass selectedIds to operations', () => {
|
|
render(
|
|
<Operations
|
|
{...defaultProps}
|
|
selectedIds={['doc-123', 'doc-456']}
|
|
onSelectedIdChange={vi.fn()}
|
|
/>,
|
|
)
|
|
// Component should render correctly with selectedIds
|
|
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|