import type { IExplore } from '@/context/explore-context' /** * Integration test: Sidebar Lifecycle Flow * * Tests the sidebar interactions for installed apps lifecycle: * navigation, pin/unpin ordering, delete confirmation, and * fold/unfold behavior. */ import type { InstalledApp } from '@/models/explore' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import Toast from '@/app/components/base/toast' import SideBar from '@/app/components/explore/sidebar' import ExploreContext from '@/context/explore-context' import { MediaType } from '@/hooks/use-breakpoints' import { AppModeEnum } from '@/types/app' let mockMediaType: string = MediaType.pc const mockSegments = ['apps'] const mockPush = vi.fn() const mockRefetch = vi.fn() const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockInstalledApps: InstalledApp[] = [] vi.mock('next/navigation', () => ({ useSelectedLayoutSegments: () => mockSegments, useRouter: () => ({ push: mockPush, }), })) vi.mock('@/hooks/use-breakpoints', () => ({ default: () => mockMediaType, MediaType: { mobile: 'mobile', tablet: 'tablet', pc: 'pc', }, })) vi.mock('@/service/use-explore', () => ({ useGetInstalledApps: () => ({ isFetching: false, data: { installed_apps: mockInstalledApps }, refetch: mockRefetch, }), useUninstallApp: () => ({ mutateAsync: mockUninstall, }), useUpdateAppPinStatus: () => ({ mutateAsync: mockUpdatePinStatus, }), })) const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ id: overrides.id ?? 'app-1', 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 ?? 'App One', description: overrides.app?.description ?? 'desc', use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, }, }) const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({ controlUpdateInstalledApps: 0, setControlUpdateInstalledApps: vi.fn(), hasEditPermission: true, installedApps, setInstalledApps: vi.fn(), isFetchingInstalledApps: false, setIsFetchingInstalledApps: vi.fn(), isShowTryAppPanel: false, setShowTryAppPanel: vi.fn(), }) const renderSidebar = (installedApps: InstalledApp[] = []) => { return render( , ) } describe('Sidebar Lifecycle Flow', () => { beforeEach(() => { vi.clearAllMocks() mockMediaType = MediaType.pc mockInstalledApps = [] vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) describe('Pin / Unpin / Delete Flow', () => { it('should complete pin → unpin cycle for an app', async () => { mockUpdatePinStatus.mockResolvedValue(undefined) // Step 1: Start with an unpinned app and pin it const unpinnedApp = createInstalledApp({ is_pinned: false }) mockInstalledApps = [unpinnedApp] const { unmount } = renderSidebar(mockInstalledApps) fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.pin')) await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', })) }) // Step 2: Simulate refetch returning pinned state, then unpin unmount() vi.clearAllMocks() mockUpdatePinStatus.mockResolvedValue(undefined) const pinnedApp = createInstalledApp({ is_pinned: true }) mockInstalledApps = [pinnedApp] renderSidebar(mockInstalledApps) fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.unpin')) await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', })) }) }) it('should complete the delete flow with confirmation', async () => { const app = createInstalledApp() mockInstalledApps = [app] mockUninstall.mockResolvedValue(undefined) renderSidebar(mockInstalledApps) // Step 1: Open operation menu and click delete fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) // Step 2: Confirm dialog appears expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument() // Step 3: Confirm deletion fireEvent.click(screen.getByText('common.operation.confirm')) // Step 4: Uninstall API called and success toast shown await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('app-1') expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', message: 'common.api.remove', })) }) }) it('should cancel deletion when user clicks cancel', async () => { const app = createInstalledApp() mockInstalledApps = [app] renderSidebar(mockInstalledApps) // Open delete flow fireEvent.click(screen.getByTestId('item-operation-trigger')) fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) // Cancel the deletion fireEvent.click(await screen.findByText('common.operation.cancel')) // Uninstall should not be called expect(mockUninstall).not.toHaveBeenCalled() }) }) describe('Multi-App Ordering', () => { it('should display pinned apps before unpinned apps with divider', () => { mockInstalledApps = [ createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }), createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }), ] const { container } = renderSidebar(mockInstalledApps) // Both apps are rendered const pinnedApp = screen.getByText('Pinned App') const regularApp = screen.getByText('Regular App') expect(pinnedApp).toBeInTheDocument() expect(regularApp).toBeInTheDocument() // Pinned app appears before unpinned app in the DOM const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')! const regularItem = regularApp.closest('[class*="rounded-lg"]')! expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() // Divider is rendered between pinned and unpinned sections const divider = container.querySelector('[class*="bg-divider-regular"]') expect(divider).toBeInTheDocument() }) }) describe('Empty State', () => { it('should show NoApps component when no apps are installed on desktop', () => { mockMediaType = MediaType.pc renderSidebar([]) expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument() }) it('should hide NoApps on mobile', () => { mockMediaType = MediaType.mobile renderSidebar([]) expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument() }) }) })