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

View File

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