mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
test(skill): add comprehensive unit tests for file-tree domain
This commit is contained in:
@ -0,0 +1,165 @@
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import {
|
||||
useExistingSkillNames,
|
||||
useSkillAssetNodeMap,
|
||||
useSkillAssetTreeData,
|
||||
} from './use-skill-asset-tree'
|
||||
|
||||
const { mockUseGetAppAssetTree } = vi.hoisted(() => ({
|
||||
mockUseGetAppAssetTree: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-app-asset', () => ({
|
||||
useGetAppAssetTree: (...args: unknown[]) => mockUseGetAppAssetTree(...args),
|
||||
}))
|
||||
|
||||
const createTreeNode = (
|
||||
overrides: Partial<AppAssetTreeView> & Pick<AppAssetTreeView, 'id' | 'node_type' | 'name'>,
|
||||
): AppAssetTreeView => ({
|
||||
id: overrides.id,
|
||||
node_type: overrides.node_type,
|
||||
name: overrides.name,
|
||||
path: overrides.path ?? `/${overrides.name}`,
|
||||
extension: overrides.extension ?? '',
|
||||
size: overrides.size ?? 0,
|
||||
children: overrides.children ?? [],
|
||||
})
|
||||
|
||||
describe('useSkillAssetTree', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
mockUseGetAppAssetTree.mockReturnValue({
|
||||
data: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should pass app id from app store to the data query hook.
|
||||
describe('useSkillAssetTreeData', () => {
|
||||
it('should request tree data with current app id', () => {
|
||||
const expectedResult = { data: { children: [] }, isPending: false }
|
||||
mockUseGetAppAssetTree.mockReturnValue(expectedResult)
|
||||
|
||||
const { result } = renderHook(() => useSkillAssetTreeData())
|
||||
|
||||
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('app-1')
|
||||
expect(result.current).toBe(expectedResult)
|
||||
})
|
||||
|
||||
it('should request tree data with empty app id when app detail is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
renderHook(() => useSkillAssetTreeData())
|
||||
|
||||
expect(mockUseGetAppAssetTree).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should expose a select transform that builds node lookup maps.
|
||||
describe('useSkillAssetNodeMap', () => {
|
||||
it('should build a map including nested nodes', () => {
|
||||
renderHook(() => useSkillAssetNodeMap())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
const map = options.select({
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-1',
|
||||
node_type: 'folder',
|
||||
name: 'skill-a',
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'file-1',
|
||||
node_type: 'file',
|
||||
name: 'README.md',
|
||||
extension: 'md',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(map.get('folder-1')?.name).toBe('skill-a')
|
||||
expect(map.get('file-1')?.name).toBe('README.md')
|
||||
expect(map.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should return an empty map when tree response has no children', () => {
|
||||
renderHook(() => useSkillAssetNodeMap())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
|
||||
}
|
||||
|
||||
const map = options.select({} as AppAssetTreeResponse)
|
||||
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: should expose root-level existing skill folder names.
|
||||
describe('useExistingSkillNames', () => {
|
||||
it('should collect only root folder names', () => {
|
||||
renderHook(() => useExistingSkillNames())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Set<string>
|
||||
}
|
||||
|
||||
const names = options.select({
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-1',
|
||||
node_type: 'folder',
|
||||
name: 'skill-a',
|
||||
children: [
|
||||
createTreeNode({
|
||||
id: 'folder-2',
|
||||
node_type: 'folder',
|
||||
name: 'nested-folder',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createTreeNode({
|
||||
id: 'file-1',
|
||||
node_type: 'file',
|
||||
name: 'README.md',
|
||||
extension: 'md',
|
||||
}),
|
||||
createTreeNode({
|
||||
id: 'folder-3',
|
||||
node_type: 'folder',
|
||||
name: 'skill-b',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(names.has('skill-a')).toBe(true)
|
||||
expect(names.has('skill-b')).toBe(true)
|
||||
expect(names.has('nested-folder')).toBe(false)
|
||||
expect(names.size).toBe(2)
|
||||
})
|
||||
|
||||
it('should return an empty set when tree response has no children', () => {
|
||||
renderHook(() => useExistingSkillNames())
|
||||
|
||||
const options = mockUseGetAppAssetTree.mock.calls[0][1] as {
|
||||
select: (data: AppAssetTreeResponse) => Set<string>
|
||||
}
|
||||
|
||||
const names = options.select({} as AppAssetTreeResponse)
|
||||
|
||||
expect(names.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,168 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { App, AppSSO } from '@/types/app'
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
useSkillTreeCollaboration,
|
||||
useSkillTreeUpdateEmitter,
|
||||
} from './use-skill-tree-collaboration'
|
||||
|
||||
const {
|
||||
mockEmitTreeUpdate,
|
||||
mockOnTreeUpdate,
|
||||
mockUnsubscribe,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEmitTreeUpdate: vi.fn(),
|
||||
mockOnTreeUpdate: vi.fn(),
|
||||
mockUnsubscribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/skills/skill-collaboration-manager', () => ({
|
||||
skillCollaborationManager: {
|
||||
emitTreeUpdate: mockEmitTreeUpdate,
|
||||
onTreeUpdate: mockOnTreeUpdate,
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSkillTreeCollaboration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
appDetail: { id: 'app-1' } as App & Partial<AppSSO>,
|
||||
})
|
||||
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: true,
|
||||
},
|
||||
})
|
||||
|
||||
mockOnTreeUpdate.mockReturnValue(mockUnsubscribe)
|
||||
})
|
||||
|
||||
// Scenario: update emitter sends events only when collaboration is enabled and app id exists.
|
||||
describe('useSkillTreeUpdateEmitter', () => {
|
||||
it('should emit tree update with app id and payload', () => {
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
|
||||
act(() => {
|
||||
result.current({ source: 'test' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).toHaveBeenCalledWith('app-1', { source: 'test' })
|
||||
})
|
||||
|
||||
it('should not emit tree update when collaboration is disabled', () => {
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: false,
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
act(() => {
|
||||
result.current({ source: 'disabled' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not emit tree update when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
const { result } = renderHook(() => useSkillTreeUpdateEmitter())
|
||||
act(() => {
|
||||
result.current({ source: 'no-app' })
|
||||
})
|
||||
|
||||
expect(mockEmitTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Scenario: collaboration hook subscribes to updates and invalidates tree query cache.
|
||||
describe('useSkillTreeCollaboration', () => {
|
||||
it('should subscribe to tree updates and invalidate app tree query when updates arrive', async () => {
|
||||
let treeUpdateCallback: ((payload: Record<string, unknown>) => void) | null = null
|
||||
mockOnTreeUpdate.mockImplementation((_appId: string, callback: (payload: Record<string, unknown>) => void) => {
|
||||
treeUpdateCallback = callback
|
||||
return mockUnsubscribe
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).toHaveBeenCalledWith('app-1', expect.any(Function))
|
||||
|
||||
act(() => {
|
||||
treeUpdateCallback?.({ reason: 'remote' })
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.appAsset.tree.queryKey({ input: { params: { appId: 'app-1' } } }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should clean up tree update subscription on unmount', () => {
|
||||
const queryClient = new QueryClient()
|
||||
const { unmount } = renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip subscription when collaboration is disabled', () => {
|
||||
const currentFeatures = useGlobalPublicStore.getState().systemFeatures
|
||||
useGlobalPublicStore.setState({
|
||||
systemFeatures: {
|
||||
...currentFeatures,
|
||||
enable_collaboration_mode: false,
|
||||
},
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip subscription when app id is missing', () => {
|
||||
useAppStore.setState({ appDetail: undefined })
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
renderHook(() => useSkillTreeCollaboration(), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
expect(mockOnTreeUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user