test(skill): add comprehensive unit tests for file-tree domain

This commit is contained in:
yyh
2026-02-07 16:53:58 +08:00
parent f5a29b69a8
commit a761ab5cee
31 changed files with 6645 additions and 0 deletions

View File

@ -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()
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})