chore: some tests (#30078)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Joel 2025-12-24 14:45:33 +08:00 committed by GitHub
parent f439e081b5
commit dcde854c5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1173 additions and 2 deletions

View File

@ -0,0 +1,176 @@
import type { App, AppIconType } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
import { AppModeEnum } from '@/types/app'
import LogAnnotation from './index'
const mockRouterPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
}))
vi.mock('@/app/components/app/annotation', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="annotation" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="log" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/workflow-log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="workflow-log" data-app-id={appDetail.id} />
),
}))
const createMockApp = (overrides: Partial<App> = {}): App => ({
id: 'app-123',
name: 'Test App',
description: 'Test app description',
author_name: 'Test Author',
icon_type: 'emoji' as AppIconType,
icon: ':icon:',
icon_background: '#FFEAD5',
icon_url: null,
use_icon_as_answer_icon: false,
mode: AppModeEnum.CHAT,
enable_site: true,
enable_api: true,
api_rpm: 60,
api_rph: 3600,
is_demo: false,
model_config: {} as App['model_config'],
app_model_config: {} as App['app_model_config'],
created_at: Date.now(),
updated_at: Date.now(),
site: {
access_token: 'token',
app_base_url: 'https://example.com',
} as App['site'],
api_base_url: 'https://api.example.com',
tags: [],
access_mode: 'public_access' as App['access_mode'],
...overrides,
})
describe('LogAnnotation', () => {
beforeEach(() => {
vi.clearAllMocks()
useAppStore.setState({ appDetail: createMockApp() })
})
// Rendering behavior
describe('Rendering', () => {
it('should render loading state when app detail is missing', () => {
// Arrange
useAppStore.setState({ appDetail: undefined })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render log and annotation tabs for non-completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByText('appLog.title')).toBeInTheDocument()
expect(screen.getByText('appAnnotation.title')).toBeInTheDocument()
})
it('should render only log tab for completion apps', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.COMPLETION }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByText('appLog.title')).toBeInTheDocument()
expect(screen.queryByText('appAnnotation.title')).not.toBeInTheDocument()
})
it('should hide tabs and render workflow log in workflow mode', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.WORKFLOW }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.queryByText('appLog.title')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-log')).toBeInTheDocument()
})
})
// Prop-driven behavior
describe('Props', () => {
it('should render log content when page type is log', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.log} />)
// Assert
expect(screen.getByTestId('log')).toBeInTheDocument()
expect(screen.queryByTestId('annotation')).not.toBeInTheDocument()
})
it('should render annotation content when page type is annotation', () => {
// Arrange
useAppStore.setState({ appDetail: createMockApp({ mode: AppModeEnum.CHAT }) })
// Act
render(<LogAnnotation pageType={PageType.annotation} />)
// Assert
expect(screen.getByTestId('annotation')).toBeInTheDocument()
expect(screen.queryByTestId('log')).not.toBeInTheDocument()
})
})
// User interaction behavior
describe('User Interactions', () => {
it('should navigate to annotations when switching from log tab', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<LogAnnotation pageType={PageType.log} />)
await user.click(screen.getByText('appAnnotation.title'))
// Assert
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/annotations')
})
it('should navigate to logs when switching from annotation tab', async () => {
// Arrange
const user = userEvent.setup()
// Act
render(<LogAnnotation pageType={PageType.annotation} />)
await user.click(screen.getByText('appLog.title'))
// Assert
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/logs')
})
})
})

View File

@ -110,7 +110,11 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({
{...props}
/>
{showClearIcon && value && !disabled && !destructive && (
<div className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')} onClick={onClear}>
<div
className={cn('group absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer p-[1px]')}
onClick={onClear}
data-testid="input-clear"
>
<RiCloseCircleFill className="h-3.5 w-3.5 cursor-pointer text-text-quaternary group-hover:text-text-tertiary" />
</div>
)}

View File

@ -0,0 +1,125 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { createMockPlan } from '@/__mocks__/provider-context'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import PriorityLabel from './index'
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
const useProviderContextMock = useProviderContext as Mock
const setupPlan = (planType: Plan) => {
useProviderContextMock.mockReturnValue(createMockPlan(planType))
}
describe('PriorityLabel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: basic label output for sandbox plan.
describe('Rendering', () => {
it('should render the standard priority label when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
})
})
// Props: custom class name applied to the label container.
describe('Props', () => {
it('should apply custom className to the label container', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel className="custom-class" />)
// Assert
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
expect(label).toHaveClass('custom-class')
})
})
// Plan types: label text and icon visibility for different plans.
describe('Plan Types', () => {
it('should render priority label and icon when plan is professional', () => {
// Arrange
setupPlan(Plan.professional)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render top priority label and icon when plan is team', () => {
// Arrange
setupPlan(Plan.team)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.top-priority')).toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render standard label without icon when plan is sandbox', () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
const { container } = render(<PriorityLabel />)
// Assert
expect(screen.getByText('billing.plansCommon.priority.standard')).toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeInTheDocument()
})
})
// Edge cases: tooltip content varies by priority level.
describe('Edge Cases', () => {
it('should show the tip text when priority is not top priority', async () => {
// Arrange
setupPlan(Plan.sandbox)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.standard').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.standard',
)).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.documentProcessingPriorityTip')).toBeInTheDocument()
})
it('should hide the tip text when priority is top priority', async () => {
// Arrange
setupPlan(Plan.enterprise)
// Act
render(<PriorityLabel />)
const label = screen.getByText('billing.plansCommon.priority.top-priority').closest('div')
fireEvent.mouseEnter(label as HTMLElement)
// Assert
expect(await screen.findByText(
'billing.plansCommon.documentProcessingPriority: billing.plansCommon.priority.top-priority',
)).toBeInTheDocument()
expect(screen.queryByText('billing.plansCommon.documentProcessingPriorityTip')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,271 @@
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ExploreContext from '@/context/explore-context'
import { fetchAppDetail } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import AppList from './index'
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
let mockTabValue = allCategoriesEn
const mockSetTab = vi.fn()
let mockSWRData: { categories: string[], allList: App[] } = { categories: [], allList: [] }
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
vi.mock('@/hooks/use-tab-searchparams', () => ({
useTabSearchParams: () => [mockTabValue, mockSetTab],
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
const React = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const fnRef = React.useRef(fn)
fnRef.current = fn
return {
run: () => setTimeout(() => fnRef.current(), 0),
}
},
}
})
vi.mock('swr', () => ({
__esModule: true,
default: () => ({ data: mockSWRData }),
}))
vi.mock('@/service/explore', () => ({
fetchAppDetail: vi.fn(),
fetchAppList: vi.fn(),
}))
vi.mock('@/hooks/use-import-dsl', () => ({
useImportDSL: () => ({
handleImportDSL: mockHandleImportDSL,
handleImportDSLConfirm: mockHandleImportDSLConfirm,
versions: ['v1'],
isFetching: false,
}),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
__esModule: true,
default: (props: CreateAppModalProps) => {
if (!props.show)
return null
return (
<div data-testid="create-app-modal">
<button
data-testid="confirm-create"
onClick={() => props.onConfirm({
name: 'New App',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
description: 'desc',
})}
>
confirm
</button>
<button data-testid="hide-create" onClick={props.onHide}>hide</button>
</div>
)
},
}))
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
__esModule: true,
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
<div data-testid="dsl-confirm-modal">
<button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button>
<button data-testid="dsl-cancel" onClick={onCancel}>cancel</button>
</div>
),
}))
const createApp = (overrides: Partial<App> = {}): App => ({
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '😀',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'Alpha',
description: overrides.app?.description ?? 'Alpha description',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
app_id: overrides.app_id ?? 'app-1',
description: overrides.description ?? 'Alpha description',
copyright: overrides.copyright ?? '',
privacy_policy: overrides.privacy_policy ?? null,
custom_disclaimer: overrides.custom_disclaimer ?? null,
category: overrides.category ?? 'Writing',
position: overrides.position ?? 1,
is_listed: overrides.is_listed ?? true,
install_count: overrides.install_count ?? 0,
installed: overrides.installed ?? false,
editable: overrides.editable ?? false,
is_agent: overrides.is_agent ?? false,
})
const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission,
installedApps: [],
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
}}
>
<AppList onSuccess={onSuccess} />
</ExploreContext.Provider>,
)
}
describe('AppList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTabValue = allCategoriesEn
mockSWRData = { categories: [], allList: [] }
})
// Rendering: show loading when categories are not ready.
describe('Rendering', () => {
it('should render loading when categories are empty', () => {
// Arrange
mockSWRData = { categories: [], allList: [] }
// Act
renderWithContext()
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render app cards when data is available', () => {
// Arrange
mockSWRData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
// Act
renderWithContext()
// Assert
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Beta')).toBeInTheDocument()
})
})
// Props: category selection filters the list.
describe('Props', () => {
it('should filter apps by selected category', () => {
// Arrange
mockTabValue = 'Writing'
mockSWRData = {
categories: ['Writing', 'Translate'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
}
// Act
renderWithContext()
// Assert
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
})
})
// User interactions: search and create flow.
describe('User Interactions', () => {
it('should filter apps by search keywords', async () => {
// Arrange
mockSWRData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
// Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
// Assert
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
})
it('should handle create flow and confirm DSL when pending', async () => {
// Arrange
const onSuccess = vi.fn()
mockSWRData = {
categories: ['Writing'],
allList: [createApp()],
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
options.onSuccess?.()
})
// Act
renderWithContext(true, onSuccess)
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
fireEvent.click(await screen.findByTestId('confirm-create'))
// Assert
await waitFor(() => {
expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
})
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('dsl-confirm'))
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
expect(onSuccess).toHaveBeenCalledTimes(1)
})
})
})
// Edge cases: handle clearing search keywords.
describe('Edge Cases', () => {
it('should reset search results when clear icon is clicked', async () => {
// Arrange
mockSWRData = {
categories: ['Writing'],
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
}
renderWithContext()
// Act
const input = screen.getByPlaceholderText('common.operation.search')
fireEvent.change(input, { target: { value: 'gam' } })
await waitFor(() => {
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('input-clear'))
// Assert
await waitFor(() => {
expect(screen.getByText('Alpha')).toBeInTheDocument()
expect(screen.getByText('Gamma')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,79 @@
import type { AppCategory } from '@/models/explore'
import { fireEvent, render, screen } from '@testing-library/react'
import Category from './category'
describe('Category', () => {
const allCategoriesEn = 'Recommended'
const renderComponent = (overrides: Partial<React.ComponentProps<typeof Category>> = {}) => {
const props: React.ComponentProps<typeof Category> = {
list: ['Writing', 'Recommended'] as AppCategory[],
value: allCategoriesEn,
onChange: vi.fn(),
allCategoriesEn,
...overrides,
}
return {
props,
...render(<Category {...props} />),
}
}
// Rendering: basic categories and all-categories button.
describe('Rendering', () => {
it('should render all categories item and translated categories', () => {
// Arrange
renderComponent()
// Assert
expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument()
expect(screen.getByText('explore.category.Writing')).toBeInTheDocument()
})
it('should not render allCategoriesEn again inside the category list', () => {
// Arrange
renderComponent()
// Assert
const recommendedItems = screen.getAllByText('explore.apps.allCategories')
expect(recommendedItems).toHaveLength(1)
})
})
// Props: clicking items triggers onChange.
describe('Props', () => {
it('should call onChange with category value when category item is clicked', () => {
// Arrange
const { props } = renderComponent()
// Act
fireEvent.click(screen.getByText('explore.category.Writing'))
// Assert
expect(props.onChange).toHaveBeenCalledWith('Writing')
})
it('should call onChange with allCategoriesEn when all categories is clicked', () => {
// Arrange
const { props } = renderComponent({ value: 'Writing' })
// Act
fireEvent.click(screen.getByText('explore.apps.allCategories'))
// Assert
expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn)
})
})
// Edge cases: handle values not in the list.
describe('Edge Cases', () => {
it('should treat unknown value as all categories selection', () => {
// Arrange
renderComponent({ value: 'Unknown' })
// Assert
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
})
})
})

View File

@ -0,0 +1,140 @@
import type { Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useContext } from 'use-context-selector'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useMembers } from '@/service/use-common'
import Explore from './index'
const mockReplace = vi.fn()
const mockPush = vi.fn()
const mockInstalledAppsData = { installed_apps: [] as const }
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
push: mockPush,
}),
useSelectedLayoutSegments: () => ['apps'],
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => MediaType.pc,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: false,
data: mockInstalledAppsData,
refetch: vi.fn(),
}),
useUninstallApp: () => ({
mutateAsync: vi.fn(),
}),
useUpdateAppPinStatus: () => ({
mutateAsync: vi.fn(),
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: vi.fn(),
}))
const ContextReader = () => {
const { hasEditPermission } = useContext(ExploreContext)
return <div>{hasEditPermission ? 'edit-yes' : 'edit-no'}</div>
}
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: provides ExploreContext and children.
describe('Rendering', () => {
it('should render children and provide edit permission from members role', async () => {
// Arrange
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({
data: {
accounts: [{ id: 'user-1', role: 'admin' }],
},
})
// Act
render((
<Explore>
<ContextReader />
</Explore>
))
// Assert
await waitFor(() => {
expect(screen.getByText('edit-yes')).toBeInTheDocument()
})
})
})
// Effects: set document title and redirect dataset operators.
describe('Effects', () => {
it('should set document title on render', () => {
// Arrange
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: false,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
// Act
render((
<Explore>
<div>child</div>
</Explore>
))
// Assert
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
})
it('should redirect dataset operators to /datasets', async () => {
// Arrange
; (useAppContext as Mock).mockReturnValue({
userProfile: { id: 'user-1' },
isCurrentWorkspaceDatasetOperator: true,
});
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
// Act
render((
<Explore>
<div>child</div>
</Explore>
))
// Assert
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
})
})

View File

@ -0,0 +1,109 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ItemOperation from './index'
describe('ItemOperation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const renderComponent = (overrides: Partial<React.ComponentProps<typeof ItemOperation>> = {}) => {
const props: React.ComponentProps<typeof ItemOperation> = {
isPinned: false,
isShowDelete: true,
togglePin: vi.fn(),
onDelete: vi.fn(),
...overrides,
}
return {
props,
...render(<ItemOperation {...props} />),
}
}
// Rendering: menu items show after opening.
describe('Rendering', () => {
it('should render pin and delete actions when menu is open', async () => {
// Arrange
renderComponent()
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
// Assert
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
})
})
// Props: render optional rename action and pinned label text.
describe('Props', () => {
it('should render rename action when isShowRenameConversation is true', async () => {
// Arrange
renderComponent({ isShowRenameConversation: true })
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
// Assert
expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
})
it('should render unpin label when isPinned is true', async () => {
// Arrange
renderComponent({ isPinned: true })
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
// Assert
expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
})
})
// User interactions: clicking action items triggers callbacks.
describe('User Interactions', () => {
it('should call togglePin when clicking pin action', async () => {
// Arrange
const { props } = renderComponent()
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
// Assert
expect(props.togglePin).toHaveBeenCalledTimes(1)
})
it('should call onDelete when clicking delete action', async () => {
// Arrange
const { props } = renderComponent()
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Assert
expect(props.onDelete).toHaveBeenCalledTimes(1)
})
})
// Edge cases: menu closes after mouse leave when no hovering state remains.
describe('Edge Cases', () => {
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
// Arrange
renderComponent()
fireEvent.click(screen.getByTestId('item-operation-trigger'))
const pinText = await screen.findByText('explore.sidebar.action.pin')
const menu = pinText.closest('div')?.parentElement as HTMLElement
// Act
fireEvent.mouseEnter(menu)
fireEvent.mouseLeave(menu)
// Assert
await waitFor(() => {
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
})
})
})
})

View File

@ -53,7 +53,11 @@ const ItemOperation: FC<IItemOperationProps> = ({
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
>
<div className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} !bg-components-actionbar-bg !shadow-none`)}></div>
<div
className={cn(className, s.btn, 'h-6 w-6 rounded-md border-none py-1', (isItemHovering || open) && `${s.open} !bg-components-actionbar-bg !shadow-none`)}
data-testid="item-operation-trigger"
>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
className="z-50"

View File

@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AppNavItem from './index'
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
...actual,
useHover: () => false,
}
})
const baseProps = {
isMobile: false,
name: 'My App',
id: 'app-123',
icon_type: 'emoji' as const,
icon: '🤖',
icon_background: '#fff',
icon_url: '',
isSelected: false,
isPinned: false,
togglePin: vi.fn(),
uninstallable: false,
onDelete: vi.fn(),
}
describe('AppNavItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering: display app name for desktop and hide for mobile.
describe('Rendering', () => {
it('should render name and item operation on desktop', () => {
// Arrange
render(<AppNavItem {...baseProps} />)
// Assert
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
})
it('should hide name on mobile', () => {
// Arrange
render(<AppNavItem {...baseProps} isMobile />)
// Assert
expect(screen.queryByText('My App')).not.toBeInTheDocument()
})
})
// User interactions: navigation and delete flow.
describe('User Interactions', () => {
it('should navigate to installed app when item is clicked', () => {
// Arrange
render(<AppNavItem {...baseProps} />)
// Act
fireEvent.click(screen.getByText('My App'))
// Assert
expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123')
})
it('should call onDelete with app id when delete action is clicked', async () => {
// Arrange
render(<AppNavItem {...baseProps} />)
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
// Assert
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
})
})
// Edge cases: hide delete when uninstallable or selected.
describe('Edge Cases', () => {
it('should not render delete action when app is uninstallable', () => {
// Arrange
render(<AppNavItem {...baseProps} uninstallable />)
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
// Assert
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,164 @@
import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import ExploreContext from '@/context/explore-context'
import { MediaType } from '@/hooks/use-breakpoints'
import { AppModeEnum } from '@/types/app'
import SideBar from './index'
const mockSegments = ['apps']
const mockPush = vi.fn()
const mockRefetch = vi.fn()
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockIsFetching = false
let mockInstalledApps: InstalledApp[] = []
vi.mock('next/navigation', () => ({
useSelectedLayoutSegments: () => mockSegments,
useRouter: () => ({
push: mockPush,
}),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => MediaType.pc,
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
}))
vi.mock('@/service/use-explore', () => ({
useGetInstalledApps: () => ({
isFetching: mockIsFetching,
data: { installed_apps: mockInstalledApps },
refetch: mockRefetch,
}),
useUninstallApp: () => ({
mutateAsync: mockUninstall,
}),
useUpdateAppPinStatus: () => ({
mutateAsync: mockUpdatePinStatus,
}),
}))
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
id: overrides.id ?? 'app-123',
uninstallable: overrides.uninstallable ?? false,
is_pinned: overrides.is_pinned ?? false,
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '🤖',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'My App',
description: overrides.app?.description ?? 'desc',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
})
const renderWithContext = (installedApps: InstalledApp[] = []) => {
return render(
<ExploreContext.Provider
value={{
controlUpdateInstalledApps: 0,
setControlUpdateInstalledApps: vi.fn(),
hasEditPermission: true,
installedApps,
setInstalledApps: vi.fn(),
isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(),
}}
>
<SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>,
)
}
describe('SideBar', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsFetching = false
mockInstalledApps = []
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
// Rendering: show discovery and workspace section.
describe('Rendering', () => {
it('should render workspace items when installed apps exist', () => {
// Arrange
mockInstalledApps = [createInstalledApp()]
// Act
renderWithContext(mockInstalledApps)
// Assert
expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument()
})
})
// Effects: refresh and sync installed apps state.
describe('Effects', () => {
it('should refetch installed apps on mount', () => {
// Arrange
mockInstalledApps = [createInstalledApp()]
// Act
renderWithContext(mockInstalledApps)
// Assert
expect(mockRefetch).toHaveBeenCalledTimes(1)
})
})
// User interactions: delete and pin flows.
describe('User Interactions', () => {
it('should uninstall app and show toast when delete is confirmed', async () => {
// Arrange
mockInstalledApps = [createInstalledApp()]
mockUninstall.mockResolvedValue(undefined)
renderWithContext(mockInstalledApps)
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
fireEvent.click(await screen.findByText('common.operation.confirm'))
// Assert
await waitFor(() => {
expect(mockUninstall).toHaveBeenCalledWith('app-123')
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.remove',
}))
})
})
it('should update pin status and show toast when pin is clicked', async () => {
// Arrange
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
mockUpdatePinStatus.mockResolvedValue(undefined)
renderWithContext(mockInstalledApps)
// Act
fireEvent.click(screen.getByTestId('item-operation-trigger'))
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
// Assert
await waitFor(() => {
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.success',
}))
})
})
})
})