mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test(skill): add comprehensive unit tests for file-tree domain
This commit is contained in:
@ -0,0 +1,100 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FileTabItem from './file-tab-item'
|
||||
|
||||
type FileTabItemProps = ComponentProps<typeof FileTabItem>
|
||||
|
||||
const createProps = (overrides: Partial<FileTabItemProps> = {}) => {
|
||||
const onClick = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const onDoubleClick = vi.fn()
|
||||
|
||||
const props: FileTabItemProps = {
|
||||
fileId: 'file-1',
|
||||
name: 'readme.md',
|
||||
extension: 'md',
|
||||
isActive: false,
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
onClick,
|
||||
onClose,
|
||||
onDoubleClick,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return { props, onClick, onClose, onDoubleClick }
|
||||
}
|
||||
|
||||
describe('FileTabItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior for the tab label and close action.
|
||||
describe('Rendering', () => {
|
||||
it('should render the file tab button and close button', () => {
|
||||
const { props } = createProps()
|
||||
|
||||
render(<FileTabItem {...props} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /readme\.md/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.close/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should style the file name as preview when isPreview is true', () => {
|
||||
const { props } = createProps({ isPreview: true })
|
||||
|
||||
render(<FileTabItem {...props} />)
|
||||
|
||||
expect(screen.getByText('readme.md')).toHaveClass('italic')
|
||||
})
|
||||
})
|
||||
|
||||
// Pointer interactions should trigger the corresponding callbacks.
|
||||
describe('Interactions', () => {
|
||||
it('should call onClick with file id when the tab is clicked', () => {
|
||||
const { props, onClick } = createProps()
|
||||
|
||||
render(<FileTabItem {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /readme\.md/i }))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should call onDoubleClick with file id when preview tab is double clicked', () => {
|
||||
const { props, onDoubleClick } = createProps({ isPreview: true })
|
||||
|
||||
render(<FileTabItem {...props} />)
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i }))
|
||||
|
||||
expect(onDoubleClick).toHaveBeenCalledTimes(1)
|
||||
expect(onDoubleClick).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should not call onDoubleClick when tab is not in preview mode', () => {
|
||||
const { props, onDoubleClick } = createProps({ isPreview: false })
|
||||
|
||||
render(<FileTabItem {...props} />)
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: /readme\.md/i }))
|
||||
|
||||
expect(onDoubleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose and stop propagation when close button is clicked', () => {
|
||||
const parentClick = vi.fn()
|
||||
const { props, onClose } = createProps()
|
||||
|
||||
render(
|
||||
<div onClick={parentClick}>
|
||||
<FileTabItem {...props} />
|
||||
</div>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith('file-1')
|
||||
expect(parentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,239 @@
|
||||
import type { AppAssetTreeView } from '@/types/app-asset'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { makeArtifactTabId, START_TAB_ID } from '../../constants'
|
||||
import FileTabs from './file-tabs'
|
||||
|
||||
type MockWorkflowState = {
|
||||
openTabIds: string[]
|
||||
activeTabId: string | null
|
||||
previewTabId: string | null
|
||||
dirtyContents: Set<string>
|
||||
dirtyMetadataIds: Set<string>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storeState: {
|
||||
openTabIds: [],
|
||||
activeTabId: '__start__',
|
||||
previewTabId: null,
|
||||
dirtyContents: new Set<string>(),
|
||||
dirtyMetadataIds: new Set<string>(),
|
||||
} as MockWorkflowState,
|
||||
nodeMap: undefined as Map<string, AppAssetTreeView> | undefined,
|
||||
activateTab: vi.fn(),
|
||||
pinTab: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
clearDraftContent: vi.fn(),
|
||||
clearFileMetadata: vi.fn(),
|
||||
clearArtifactSelection: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: MockWorkflowState) => unknown) => selector(mocks.storeState),
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
activateTab: mocks.activateTab,
|
||||
pinTab: mocks.pinTab,
|
||||
closeTab: mocks.closeTab,
|
||||
clearDraftContent: mocks.clearDraftContent,
|
||||
clearFileMetadata: mocks.clearFileMetadata,
|
||||
clearArtifactSelection: mocks.clearArtifactSelection,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/file-tree/data/use-skill-asset-tree', () => ({
|
||||
useSkillAssetNodeMap: () => ({ data: mocks.nodeMap }),
|
||||
}))
|
||||
|
||||
const createNode = (overrides: Partial<AppAssetTreeView> = {}): AppAssetTreeView => ({
|
||||
id: 'file-1',
|
||||
node_type: 'file',
|
||||
name: 'guide.md',
|
||||
path: '/guide.md',
|
||||
extension: 'md',
|
||||
size: 10,
|
||||
children: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const setMockState = (overrides: Partial<MockWorkflowState> = {}) => {
|
||||
mocks.storeState.openTabIds = overrides.openTabIds ?? []
|
||||
mocks.storeState.activeTabId = overrides.activeTabId ?? START_TAB_ID
|
||||
mocks.storeState.previewTabId = overrides.previewTabId ?? null
|
||||
mocks.storeState.dirtyContents = overrides.dirtyContents ?? new Set<string>()
|
||||
mocks.storeState.dirtyMetadataIds = overrides.dirtyMetadataIds ?? new Set<string>()
|
||||
}
|
||||
|
||||
const setMockNodeMap = (nodes: AppAssetTreeView[] = []) => {
|
||||
mocks.nodeMap = new Map(nodes.map(node => [node.id, node]))
|
||||
}
|
||||
|
||||
describe('FileTabs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setMockState()
|
||||
setMockNodeMap([])
|
||||
})
|
||||
|
||||
// Rendering behavior for start tab, file tabs, and fallback naming.
|
||||
describe('Rendering', () => {
|
||||
it('should render start tab and tabs for regular and artifact files', () => {
|
||||
const artifactTabId = makeArtifactTabId('/assets/logo.png')
|
||||
setMockState({
|
||||
openTabIds: ['file-1', artifactTabId],
|
||||
activeTabId: 'file-1',
|
||||
})
|
||||
setMockNodeMap([
|
||||
createNode({ id: 'file-1', name: 'guide.md' }),
|
||||
])
|
||||
|
||||
render(<FileTabs />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /guide\.md/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /logo\.png/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to file id when node is missing from node map', () => {
|
||||
setMockState({
|
||||
openTabIds: ['missing-file-id'],
|
||||
activeTabId: 'missing-file-id',
|
||||
})
|
||||
setMockNodeMap([])
|
||||
|
||||
render(<FileTabs />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /missing-file-id/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Tab interactions should dispatch store actions.
|
||||
describe('Tab actions', () => {
|
||||
it('should activate the start tab when start tab is clicked', () => {
|
||||
render(<FileTabs />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i }))
|
||||
|
||||
expect(mocks.activateTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith(START_TAB_ID)
|
||||
})
|
||||
|
||||
it('should activate a file tab when a file tab is clicked', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: START_TAB_ID,
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /guide\.md/i }))
|
||||
|
||||
expect(mocks.activateTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.activateTab).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should pin a preview tab when it is double clicked', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: 'file-1',
|
||||
previewTabId: 'file-1',
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
fireEvent.doubleClick(screen.getByRole('button', { name: /guide\.md/i }))
|
||||
|
||||
expect(mocks.pinTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.pinTab).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should close a clean file tab and clear draft and metadata', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: 'file-1',
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.clearArtifactSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear artifact selection before closing artifact tab', () => {
|
||||
const artifactTabId = makeArtifactTabId('/assets/logo.png')
|
||||
setMockState({
|
||||
openTabIds: [artifactTabId],
|
||||
activeTabId: artifactTabId,
|
||||
})
|
||||
|
||||
render(<FileTabs />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
|
||||
expect(mocks.clearArtifactSelection).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith(artifactTabId)
|
||||
expect(mocks.clearDraftContent).toHaveBeenCalledWith(artifactTabId)
|
||||
expect(mocks.clearFileMetadata).toHaveBeenCalledWith(artifactTabId)
|
||||
})
|
||||
})
|
||||
|
||||
// Dirty tabs must show confirmation before closing.
|
||||
describe('Unsaved changes confirmation', () => {
|
||||
it('should show confirmation dialog instead of closing immediately for dirty tab', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: 'file-1',
|
||||
dirtyContents: new Set(['file-1']),
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow.skillSidebar.unsavedChanges.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close the dirty tab when user confirms', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: 'file-1',
|
||||
dirtyMetadataIds: new Set(['file-1']),
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.unsavedChanges\.confirmClose/i }))
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.closeTab).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.clearDraftContent).toHaveBeenCalledWith('file-1')
|
||||
expect(mocks.clearFileMetadata).toHaveBeenCalledWith('file-1')
|
||||
})
|
||||
|
||||
it('should keep the tab open when user cancels the close confirmation', () => {
|
||||
setMockState({
|
||||
openTabIds: ['file-1'],
|
||||
activeTabId: 'file-1',
|
||||
dirtyContents: new Set(['file-1']),
|
||||
})
|
||||
setMockNodeMap([createNode({ id: 'file-1', name: 'guide.md' })])
|
||||
|
||||
render(<FileTabs />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.close/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
expect(mocks.clearDraftContent).not.toHaveBeenCalled()
|
||||
expect(mocks.clearFileMetadata).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,61 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import StartTabItem from './start-tab-item'
|
||||
|
||||
type StartTabItemProps = ComponentProps<typeof StartTabItem>
|
||||
|
||||
const createProps = (overrides: Partial<StartTabItemProps> = {}) => {
|
||||
const onClick = vi.fn()
|
||||
const props: StartTabItemProps = {
|
||||
isActive: false,
|
||||
onClick,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
return { props, onClick }
|
||||
}
|
||||
|
||||
describe('StartTabItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering behavior for the start tab button and label.
|
||||
describe('Rendering', () => {
|
||||
it('should render the start tab button with translated label', () => {
|
||||
const { props } = createProps()
|
||||
|
||||
render(<StartTabItem {...props} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should style the start label as active when isActive is true', () => {
|
||||
const { props } = createProps({ isActive: true })
|
||||
|
||||
render(<StartTabItem {...props} />)
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-primary')
|
||||
})
|
||||
|
||||
it('should style the start label as inactive when isActive is false', () => {
|
||||
const { props } = createProps({ isActive: false })
|
||||
|
||||
render(<StartTabItem {...props} />)
|
||||
|
||||
expect(screen.getByText('workflow.skillSidebar.startTab')).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
})
|
||||
|
||||
// Clicking the tab should delegate to the callback.
|
||||
describe('Interactions', () => {
|
||||
it('should call onClick when start tab is clicked', () => {
|
||||
const { props, onClick } = createProps()
|
||||
|
||||
render(<StartTabItem {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.skillSidebar\.startTab/i }))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user