diff --git a/web/app/components/app/log-annotation/index.spec.tsx b/web/app/components/app/log-annotation/index.spec.tsx new file mode 100644 index 0000000000..064092f20e --- /dev/null +++ b/web/app/components/app/log-annotation/index.spec.tsx @@ -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 }) => ( +
+ ), +})) + +vi.mock('@/app/components/app/log', () => ({ + __esModule: true, + default: ({ appDetail }: { appDetail: App }) => ( +
+ ), +})) + +vi.mock('@/app/components/app/workflow-log', () => ({ + __esModule: true, + default: ({ appDetail }: { appDetail: App }) => ( +
+ ), +})) + +const createMockApp = (overrides: Partial = {}): 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + 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() + await user.click(screen.getByText('appLog.title')) + + // Assert + expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/logs') + }) + }) +}) diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 98529a26bc..6c6a0c6a75 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -110,7 +110,11 @@ const Input = React.forwardRef(({ {...props} /> {showClearIcon && value && !disabled && !destructive && ( -
+
)} diff --git a/web/app/components/billing/priority-label/index.spec.tsx b/web/app/components/billing/priority-label/index.spec.tsx new file mode 100644 index 0000000000..0d176d1611 --- /dev/null +++ b/web/app/components/billing/priority-label/index.spec.tsx @@ -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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + 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() + 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() + }) + }) +}) diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx new file mode 100644 index 0000000000..e73fcdf0ad --- /dev/null +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -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('ahooks') + const React = await vi.importActual('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 ( +
+ + +
+ ) + }, +})) + +vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({ + __esModule: true, + default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => ( +
+ + +
+ ), +})) + +const createApp = (overrides: Partial = {}): 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( + + + , + ) +} + +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() + }) + }) + }) +}) diff --git a/web/app/components/explore/category.spec.tsx b/web/app/components/explore/category.spec.tsx new file mode 100644 index 0000000000..a84b17c844 --- /dev/null +++ b/web/app/components/explore/category.spec.tsx @@ -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> = {}) => { + const props: React.ComponentProps = { + list: ['Writing', 'Recommended'] as AppCategory[], + value: allCategoriesEn, + onChange: vi.fn(), + allCategoriesEn, + ...overrides, + } + return { + props, + ...render(), + } + } + + // 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') + }) + }) +}) diff --git a/web/app/components/explore/index.spec.tsx b/web/app/components/explore/index.spec.tsx new file mode 100644 index 0000000000..8f361ad471 --- /dev/null +++ b/web/app/components/explore/index.spec.tsx @@ -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
{hasEditPermission ? 'edit-yes' : 'edit-no'}
+} + +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(( + + + + )) + + // 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(( + +
child
+
+ )) + + // 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(( + +
child
+
+ )) + + // Assert + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) + }) +}) diff --git a/web/app/components/explore/item-operation/index.spec.tsx b/web/app/components/explore/item-operation/index.spec.tsx new file mode 100644 index 0000000000..9084e5564e --- /dev/null +++ b/web/app/components/explore/item-operation/index.spec.tsx @@ -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> = {}) => { + const props: React.ComponentProps = { + isPinned: false, + isShowDelete: true, + togglePin: vi.fn(), + onDelete: vi.fn(), + ...overrides, + } + return { + props, + ...render(), + } + } + + // 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() + }) + }) + }) +}) diff --git a/web/app/components/explore/item-operation/index.tsx b/web/app/components/explore/item-operation/index.tsx index 3703c0d4c0..abbfdd09bd 100644 --- a/web/app/components/explore/item-operation/index.tsx +++ b/web/app/components/explore/item-operation/index.tsx @@ -53,7 +53,11 @@ const ItemOperation: FC = ({ setOpen(v => !v)} > -
+
+
({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('ahooks', async () => { + const actual = await vi.importActual('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() + + // Assert + expect(screen.getByText('My App')).toBeInTheDocument() + expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument() + }) + + it('should hide name on mobile', () => { + // Arrange + render() + + // 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() + + // 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() + + // 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() + + // Act + fireEvent.click(screen.getByTestId('item-operation-trigger')) + + // Assert + expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/explore/sidebar/index.spec.tsx b/web/app/components/explore/sidebar/index.spec.tsx new file mode 100644 index 0000000000..0cbd05aa08 --- /dev/null +++ b/web/app/components/explore/sidebar/index.spec.tsx @@ -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 => ({ + 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( + + + , + ) +} + +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', + })) + }) + }) + }) +})