diff --git a/web/app/components/base/button/sync-button.spec.tsx b/web/app/components/base/button/sync-button.spec.tsx index 8876229c28..116aaaa7b0 100644 --- a/web/app/components/base/button/sync-button.spec.tsx +++ b/web/app/components/base/button/sync-button.spec.tsx @@ -1,10 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import SyncButton from './sync-button' -vi.mock('ahooks', () => ({ - useBoolean: () => [false, { setTrue: vi.fn(), setFalse: vi.fn() }], -})) - describe('SyncButton', () => { describe('Rendering', () => { it('should render without crashing', () => { diff --git a/web/app/components/base/voice-input/index.spec.tsx b/web/app/components/base/voice-input/index.spec.tsx index 959665cd97..fa32f0425f 100644 --- a/web/app/components/base/voice-input/index.spec.tsx +++ b/web/app/components/base/voice-input/index.spec.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { audioToText } from '@/service/share' @@ -8,7 +8,6 @@ const { mockState, MockRecorder } = vi.hoisted(() => { const state = { params: {} as Record, pathname: '/test', - rafCallback: undefined as (() => void) | undefined, recorderInstances: [] as unknown[], startOverride: null as (() => Promise) | null, analyseData: new Uint8Array(1024).fill(150) as Uint8Array, @@ -55,13 +54,6 @@ vi.mock('./utils', () => ({ convertToMp3: vi.fn(() => new Blob(['test'], { type: 'audio/mp3' })), })) -vi.mock('ahooks', () => ({ - useRafInterval: vi.fn((fn: () => void) => { - mockState.rafCallback = fn - return vi.fn() - }), -})) - describe('VoiceInput', () => { const onConverted = vi.fn() const onCancel = vi.fn() @@ -70,7 +62,6 @@ describe('VoiceInput', () => { vi.clearAllMocks() mockState.params = {} mockState.pathname = '/test' - mockState.rafCallback = undefined mockState.recorderInstances = [] mockState.startOverride = null @@ -101,21 +92,6 @@ describe('VoiceInput', () => { expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00') }) - it('should increment timer via useRafInterval callback', async () => { - render() - await screen.findByText('common.voiceInput.speaking') - - act(() => { - mockState.rafCallback?.() - }) - expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01') - - act(() => { - mockState.rafCallback?.() - }) - expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:02') - }) - it('should call onCancel when recording start fails', async () => { mockState.startOverride = () => Promise.reject(new Error('Permission denied')) @@ -177,32 +153,6 @@ describe('VoiceInput', () => { expect(onCancel).toHaveBeenCalled() }) - it('should automatically stop recording after 600 seconds', async () => { - vi.mocked(audioToText).mockResolvedValueOnce({ text: 'auto stopped' }) - mockState.params = { token: 'abc' } - - render() - await screen.findByTestId('voice-input-stop') - - for (let i = 0; i < 600; i++) - act(() => { mockState.rafCallback?.() }) - - await waitFor(() => { - expect(onConverted).toHaveBeenCalledWith('auto stopped') - }) - }) - - it('should show red timer text after 500 seconds', async () => { - render() - await screen.findByTestId('voice-input-stop') - - for (let i = 0; i < 501; i++) - act(() => { mockState.rafCallback?.() }) - - const timer = screen.getByTestId('voice-input-timer') - expect(timer.className).toContain('text-[#F04438]') - }) - it('should draw on canvas with low data values triggering v < 128 clamp', async () => { mockState.analyseData = new Uint8Array(1024).fill(50) diff --git a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts index b264324374..bbcc352144 100644 --- a/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts +++ b/web/app/components/header/account-setting/model-provider-page/hooks.spec.ts @@ -1,8 +1,22 @@ import type { Mock } from 'vitest' -import { renderHook } from '@testing-library/react' +import type { + DefaultModelResponse, + Model, +} from './declarations' +import { act, renderHook } from '@testing-library/react' import { useLocale } from '@/context/i18n' -import { useLanguage } from './hooks' +import { + ConfigurationMethodEnum, + ModelTypeEnum, +} from './declarations' +import { + useLanguage, + useModelList, + useProviderCredentialsAndLoadBalancing, + useSystemDefaultModelAndModelList, +} from './hooks' +// Mock dependencies vi.mock('@tanstack/react-query', () => ({ useQuery: vi.fn(), useQueryClient: vi.fn(() => ({ @@ -10,17 +24,6 @@ vi.mock('@tanstack/react-query', () => ({ })), })) -// mock use-context-selector -vi.mock('use-context-selector', () => ({ - useContext: vi.fn(), - createContext: () => ({ - Provider: ({ children }: any) => children, - Consumer: ({ children }: any) => children(null), - }), - useContextSelector: vi.fn(), -})) - -// mock service/common functions vi.mock('@/service/common', () => ({ fetchDefaultModal: vi.fn(), fetchModelList: vi.fn(), @@ -30,63 +33,129 @@ vi.mock('@/service/common', () => ({ vi.mock('@/service/use-common', () => ({ commonQueryKeys: { - modelProviders: ['common', 'model-providers'], + modelList: (type: string) => ['model-list', type], + modelProviders: ['model-providers'], + defaultModel: (type: string) => ['default-model', type], }, })) -// mock context hooks vi.mock('@/context/i18n', () => ({ useLocale: vi.fn(() => 'en-US'), })) -vi.mock('@/context/provider-context', () => ({ - useProviderContext: vi.fn(), -})) +const { useQuery } = await import('@tanstack/react-query') +const { fetchModelList, fetchModelProviderCredentials } = await import('@/service/common') -vi.mock('@/context/modal-context', () => ({ - useModalContextSelector: vi.fn(), -})) - -vi.mock('@/context/event-emitter', () => ({ - useEventEmitterContextContext: vi.fn(), -})) - -// mock plugins -vi.mock('@/app/components/plugins/marketplace/hooks', () => ({ - useMarketplacePlugins: vi.fn(), -})) - -vi.mock('@/app/components/plugins/marketplace/utils', () => ({ - getMarketplacePluginsByCollectionId: vi.fn(), -})) - -vi.mock('./provider-added-card', () => ({ - default: vi.fn(), -})) - -afterAll(() => { - vi.resetModules() - vi.clearAllMocks() -}) - -describe('useLanguage', () => { - it('should replace hyphen with underscore in locale', () => { - ;(useLocale as Mock).mockReturnValue('en-US') - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('en_US') +describe('hooks', () => { + afterEach(() => { + vi.clearAllMocks() }) - it('should return locale as is if no hyphen exists', () => { - ;(useLocale as Mock).mockReturnValue('enUS') + describe('useLanguage', () => { + it('should replace hyphen with underscore in locale', () => { + ;(useLocale as Mock).mockReturnValue('en-US') + const { result } = renderHook(() => useLanguage()) + expect(result.current).toBe('en_US') + }) - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('enUS') + it('should return locale as is if no hyphen exists', () => { + ;(useLocale as Mock).mockReturnValue('enUS') + const { result } = renderHook(() => useLanguage()) + expect(result.current).toBe('enUS') + }) }) - it('should handle multiple hyphens', () => { - ;(useLocale as Mock).mockReturnValue('zh-Hans-CN') + describe('useSystemDefaultModelAndModelList', () => { + it('should return default model state', () => { + const defaultModel = { + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + }, + model: 'gpt-3.5', + model_type: ModelTypeEnum.textGeneration, + } as unknown as DefaultModelResponse + const modelList = [{ provider: 'openai', models: [{ model: 'gpt-3.5' }] }] as unknown as Model[] + const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) - const { result } = renderHook(() => useLanguage()) - expect(result.current).toBe('zh_Hans-CN') + expect(result.current[0]).toEqual({ model: 'gpt-3.5', provider: 'openai' }) + }) + + it('should update default model state', () => { + const defaultModel = { + provider: { + provider: 'openai', + icon_small: { en_US: 'icon', zh_Hans: 'icon' }, + }, + model: 'gpt-3.5', + model_type: ModelTypeEnum.textGeneration, + } as any + const modelList = [{ provider: 'openai', models: [{ model: 'gpt-3.5' }] }] as any + const { result } = renderHook(() => useSystemDefaultModelAndModelList(defaultModel, modelList)) + + const newModel = { model: 'gpt-4', provider: 'openai' } + act(() => { + result.current[1](newModel) + }) + + expect(result.current[0]).toEqual(newModel) + }) + }) + + describe('useProviderCredentialsAndLoadBalancing', () => { + it('should fetch predefined credentials', async () => { + (useQuery as Mock).mockReturnValue({ + data: { credentials: { key: 'value' }, load_balancing: { enabled: true } }, + isPending: false, + }) + + const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.predefinedModel, + true, + undefined, + 'cred-id', + )) + + expect(result.current.credentials).toEqual({ key: 'value' }) + expect(result.current.loadBalancing).toEqual({ enabled: true }) + expect(fetchModelProviderCredentials).not.toHaveBeenCalled() // useQuery calls it, but we blocked it with mockReturnValue + }) + + it('should fetch custom credentials', () => { + (useQuery as Mock).mockReturnValue({ + data: { credentials: { key: 'value' }, load_balancing: { enabled: true } }, + isPending: false, + }) + + const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing( + 'openai', + ConfigurationMethodEnum.customizableModel, + true, + { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration }, + 'cred-id', + )) + + expect(result.current.credentials).toEqual({ + key: 'value', + __model_name: 'gpt-4', + __model_type: ModelTypeEnum.textGeneration, + }) + }) + }) + + describe('useModelList', () => { + it('should fetch model list', () => { + (useQuery as Mock).mockReturnValue({ + data: { data: [{ model: 'gpt-4' }] }, + isPending: false, + refetch: vi.fn(), + }) + + const { result } = renderHook(() => useModelList(ModelTypeEnum.textGeneration)) + + expect(result.current.data).toEqual([{ model: 'gpt-4' }]) + expect(fetchModelList).not.toHaveBeenCalled() + }) }) }) diff --git a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx new file mode 100644 index 0000000000..1f1832628c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx @@ -0,0 +1,199 @@ +import { act, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { + CurrentSystemQuotaTypeEnum, + CustomConfigurationStatusEnum, + QuotaUnitEnum, +} from './declarations' +import ModelProviderPage from './index' + +vi.mock('@/context/app-context', () => ({ + useAppContext: () => ({ + mutateCurrentWorkspace: vi.fn(), + isValidatingCurrentWorkspace: false, + }), +})) + +const mockGlobalState = { + systemFeatures: { enable_marketplace: true }, +} + +const mockQuotaConfig = { + quota_type: CurrentSystemQuotaTypeEnum.free, + quota_unit: QuotaUnitEnum.times, + quota_limit: 100, + quota_used: 1, + last_used: 0, + is_valid: true, +} + +vi.mock('@/context/global-public-context', () => ({ + useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState), +})) + +const mockProviders = [ + { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, + { + provider: 'anthropic', + label: { en_US: 'Anthropic' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, +] + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + modelProviders: mockProviders, + }), +})) + +const mockDefaultModelState = { + data: null, + isLoading: false, +} + +vi.mock('./hooks', () => ({ + useDefaultModel: () => mockDefaultModelState, +})) + +vi.mock('./install-from-marketplace', () => ({ + default: () =>
, +})) + +vi.mock('./provider-added-card', () => ({ + default: ({ provider }: { provider: { provider: string } }) =>
{provider.provider}
, +})) + +vi.mock('./provider-added-card/quota-panel', () => ({ + default: () =>
, +})) + +vi.mock('./system-model-selector', () => ({ + default: () =>
, +})) + +describe('ModelProviderPage', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + mockGlobalState.systemFeatures.enable_marketplace = true + mockDefaultModelState.data = null + mockDefaultModelState.isLoading = false + mockProviders.splice(0, mockProviders.length, { + provider: 'openai', + label: { en_US: 'OpenAI' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'anthropic', + label: { en_US: 'Anthropic' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should render main elements', () => { + render() + expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument() + expect(screen.getByTestId('system-model-selector')).toBeInTheDocument() + expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument() + }) + + it('should render configured and not configured providers sections', () => { + render() + expect(screen.getByText('openai')).toBeInTheDocument() + expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument() + expect(screen.getByText('anthropic')).toBeInTheDocument() + }) + + it('should filter providers based on search text', () => { + render() + act(() => { + vi.advanceTimersByTime(600) + }) + expect(screen.getByText('openai')).toBeInTheDocument() + expect(screen.queryByText('anthropic')).not.toBeInTheDocument() + }) + + it('should show empty state if no configured providers match', () => { + render() + act(() => { + vi.advanceTimersByTime(600) + }) + expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument() + }) + + it('should hide marketplace section when marketplace feature is disabled', () => { + mockGlobalState.systemFeatures.enable_marketplace = false + + render() + + expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument() + }) + + it('should prioritize fixed providers in visible order', () => { + mockProviders.splice(0, mockProviders.length, { + provider: 'zeta-provider', + label: { en_US: 'Zeta Provider' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'langgenius/anthropic/anthropic', + label: { en_US: 'Anthropic Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.active }, + system_configuration: { + enabled: false, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }, { + provider: 'langgenius/openai/openai', + label: { en_US: 'OpenAI Fixed' }, + custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure }, + system_configuration: { + enabled: true, + current_quota_type: CurrentSystemQuotaTypeEnum.free, + quota_configurations: [mockQuotaConfig], + }, + }) + + render() + + const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent) + expect(renderedProviders).toEqual([ + 'langgenius/openai/openai', + 'langgenius/anthropic/anthropic', + 'zeta-provider', + ]) + expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx new file mode 100644 index 0000000000..e15e082045 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.spec.tsx @@ -0,0 +1,109 @@ +import type { Mock } from 'vitest' +import type { ModelProvider } from './declarations' +import { fireEvent, render, screen } from '@testing-library/react' + +import { describe, expect, it, vi } from 'vitest' +import { useMarketplaceAllPlugins } from './hooks' +import InstallFromMarketplace from './install-from-marketplace' + +// Mock dependencies +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: React.ReactNode, href: string }) => {children}, +})) + +vi.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'light' }), +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/plugins/marketplace/list', () => ({ + default: ({ plugins, cardRender }: { plugins: { plugin_id: string, name: string, type?: string }[], cardRender: (plugin: { plugin_id: string, name: string, type?: string }) => React.ReactNode }) => ( +
+ {plugins.map(p => ( +
+ {cardRender(p)} +
+ ))} +
+ ), +})) + +vi.mock('@/app/components/plugins/provider-card', () => ({ + default: ({ payload }: { payload: { name: string } }) =>
{payload.name}
, +})) + +vi.mock('./hooks', () => ({ + useMarketplaceAllPlugins: vi.fn(() => ({ + plugins: [], + isLoading: false, + })), +})) + +describe('InstallFromMarketplace', () => { + const mockProviders = [] as ModelProvider[] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render expanded by default', () => { + render() + expect(screen.getByText('common.modelProvider.installProvider')).toBeInTheDocument() + expect(screen.getByTestId('plugin-list')).toBeInTheDocument() + }) + + it('should collapse when clicked', () => { + render() + fireEvent.click(screen.getByText('common.modelProvider.installProvider')) + expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument() + }) + + it('should show loading state', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [], + isLoading: true, + }) + + render() + // It's expanded by default, so loading should show immediately + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + it('should list plugins', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [{ plugin_id: '1', name: 'Plugin 1' }], + isLoading: false, + }) + + render() + // Expanded by default + expect(screen.getByText('Plugin 1')).toBeInTheDocument() + }) + + it('should hide bundle plugins from the list', () => { + (useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({ + plugins: [ + { plugin_id: '1', name: 'Plugin 1', type: 'plugin' }, + { plugin_id: '2', name: 'Bundle 1', type: 'bundle' }, + ], + isLoading: false, + }) + + render() + + expect(screen.getByText('Plugin 1')).toBeInTheDocument() + expect(screen.queryByText('Bundle 1')).not.toBeInTheDocument() + }) + + it('should render discovery link', () => { + render() + expect(screen.getByText('plugin.marketplace.difyMarketplace')).toHaveAttribute('href') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx new file mode 100644 index 0000000000..ea31ae192c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/deprecated-model-trigger.spec.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import DeprecatedModelTrigger from './deprecated-model-trigger' + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +const mockUseProviderContext = vi.hoisted(() => vi.fn()) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: mockUseProviderContext, +})) + +describe('DeprecatedModelTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'someone-else' }, { provider: 'openai' }], + }) + }) + + it('should render model name', () => { + render() + expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0) + }) + + it('should show deprecated tooltip when warn icon is hovered', async () => { + const { container } = render( + , + ) + + const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement + fireEvent.mouseEnter(tooltipTrigger) + + expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument() + }) + + it('should render when provider is not found', () => { + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'someone-else' }], + }) + + render() + expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0) + }) + + it('should not show deprecated tooltip when warn icon is disabled', async () => { + render( + , + ) + + expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx new file mode 100644 index 0000000000..0c35e87ebe --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/empty-trigger.spec.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react' +import EmptyTrigger from './empty-trigger' + +describe('EmptyTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render configure model text', () => { + render() + expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx new file mode 100644 index 0000000000..e785ec58c7 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.spec.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { + ModelFeatureEnum, + ModelFeatureTextEnum, +} from '../declarations' +import FeatureIcon from './feature-icon' + +describe('FeatureIcon', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show feature label when showFeaturesLabel is true', () => { + render( + <> + + + + + , + ) + + expect(screen.getByText(ModelFeatureTextEnum.vision)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.document)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.audio)).toBeInTheDocument() + expect(screen.getByText(ModelFeatureTextEnum.video)).toBeInTheDocument() + }) + + it('should show tooltip content on hover when showFeaturesLabel is false', async () => { + const cases: Array<{ feature: ModelFeatureEnum, text: string }> = [ + { feature: ModelFeatureEnum.vision, text: ModelFeatureTextEnum.vision }, + { feature: ModelFeatureEnum.document, text: ModelFeatureTextEnum.document }, + { feature: ModelFeatureEnum.audio, text: ModelFeatureTextEnum.audio }, + { feature: ModelFeatureEnum.video, text: ModelFeatureTextEnum.video }, + ] + + for (const { feature, text } of cases) { + const { container, unmount } = render() + fireEvent.mouseEnter(container.firstElementChild as HTMLElement) + expect(await screen.findByText(`common.modelProvider.featureSupported:{"feature":"${text}"}`)) + .toBeInTheDocument() + unmount() + } + }) + + it('should render nothing for unsupported feature', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx new file mode 100644 index 0000000000..0491bb0849 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.spec.tsx @@ -0,0 +1,126 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelSelector from './index' + +vi.mock('./model-trigger', () => ({ + default: () =>
model-trigger
, +})) + +vi.mock('./deprecated-model-trigger', () => ({ + default: ({ modelName }: { modelName: string }) =>
{`deprecated:${modelName}`}
, +})) + +vi.mock('./empty-trigger', () => ({ + default: () =>
empty-trigger
, +})) + +vi.mock('./popup', () => ({ + default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (provider: string, model: ModelItem) => void }) => ( + <> + + + + ), +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('ModelSelector', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should toggle popup and close it after selecting a model', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.getByText('select')).toBeInTheDocument() + + fireEvent.click(screen.getByText('select')) + expect(screen.queryByText('select')).not.toBeInTheDocument() + }) + + it('should call onSelect when provided', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByText('empty-trigger')) + fireEvent.click(screen.getByText('select')) + + expect(onSelect).toHaveBeenCalledWith({ provider: 'openai', model: 'gpt-4' }) + }) + + it('should close popup when popup requests hide', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.getByText('hide')).toBeInTheDocument() + + fireEvent.click(screen.getByText('hide')) + expect(screen.queryByText('hide')).not.toBeInTheDocument() + }) + + it('should not open popup when readonly', () => { + render() + + fireEvent.click(screen.getByText('empty-trigger')) + expect(screen.queryByText('select')).not.toBeInTheDocument() + }) + + it('should render deprecated trigger when defaultModel is not in list', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('deprecated:missing-model')).toBeInTheDocument() + + rerender( + , + ) + expect(screen.getByText('deprecated:')).toBeInTheDocument() + }) + + it('should render model trigger when defaultModel matches', () => { + render( + , + ) + + expect(screen.getByText('model-trigger')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx new file mode 100644 index 0000000000..8bcf362faf --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-trigger.spec.tsx @@ -0,0 +1,91 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import ModelTrigger from './model-trigger' + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useLanguage: () => 'en_US', + } +}) + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +vi.mock('../model-name', () => ({ + default: ({ modelItem }: { modelItem: ModelItem }) => {modelItem.label.en_US}, +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: {}, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('ModelTrigger', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should show model name', () => { + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + }) + + it('should show status tooltip content when model is not active', async () => { + const { container } = render( + , + ) + + const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement + fireEvent.mouseEnter(tooltipTrigger) + + expect(await screen.findByText('No Configure')).toBeInTheDocument() + }) + + it('should not show status icon when readonly', () => { + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + expect(screen.queryByText('No Configure')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx new file mode 100644 index 0000000000..af398f83ba --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.spec.tsx @@ -0,0 +1,147 @@ +import type { DefaultModel, Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import PopupItem from './popup-item' + +const mockUpdateModelList = vi.hoisted(() => vi.fn()) +const mockUpdateModelProviders = vi.hoisted(() => vi.fn()) + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useLanguage: () => 'en_US', + useUpdateModelList: () => mockUpdateModelList, + useUpdateModelProviders: () => mockUpdateModelProviders, + } +}) + +vi.mock('../model-badge', () => ({ + default: ({ children }: { children: React.ReactNode }) => {children}, +})) + +vi.mock('../model-icon', () => ({ + default: ({ modelName }: { modelName: string }) => {modelName}, +})) + +vi.mock('../model-name', () => ({ + default: ({ modelItem }: { modelItem: ModelItem }) => {modelItem.label.en_US}, +})) + +const mockSetShowModelModal = vi.hoisted(() => vi.fn()) +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowModelModal: mockSetShowModelModal, + }), +})) + +const mockUseProviderContext = vi.hoisted(() => vi.fn()) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: mockUseProviderContext, +})) + +const makeModelItem = (overrides: Partial = {}): ModelItem => ({ + model: 'gpt-4', + label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' }, + model_type: ModelTypeEnum.textGeneration, + features: [ModelFeatureEnum.vision], + fetch_from: ConfigurationMethodEnum.predefinedModel, + status: ModelStatusEnum.active, + model_properties: { mode: 'chat', context_size: 4096 }, + load_balancing_enabled: false, + ...overrides, +}) + +const makeModel = (overrides: Partial = {}): Model => ({ + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [makeModelItem()], + status: ModelStatusEnum.active, + ...overrides, +}) + +describe('PopupItem', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProviderContext.mockReturnValue({ + modelProviders: [{ provider: 'openai' }], + }) + }) + + it('should call onSelect when clicking an active model', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByText('GPT-4')) + + expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' })) + }) + + it('should not call onSelect when model is not active', () => { + const onSelect = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByText('GPT-4')) + + expect(onSelect).not.toHaveBeenCalled() + }) + + it('should open model modal when clicking add on unconfigured model', () => { + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) + + expect(mockSetShowModelModal).toHaveBeenCalled() + + const call = mockSetShowModelModal.mock.calls[0][0] as { onSaveCallback?: () => void } + call.onSaveCallback?.() + + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration) + + rerender( + , + ) + + fireEvent.click(screen.getByText('COMMON.OPERATION.ADD')) + const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as { onSaveCallback?: () => void } | undefined + call2?.onSaveCallback?.() + + expect(mockUpdateModelProviders).toHaveBeenCalled() + expect(mockUpdateModelList).toHaveBeenCalledTimes(1) + }) + + it('should show selected state when defaultModel matches', () => { + const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' } + render( + , + ) + + expect(screen.getByText('GPT-4')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx new file mode 100644 index 0000000000..4083f4a37c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.spec.tsx @@ -0,0 +1,199 @@ +import type { Model, ModelItem } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { + ConfigurationMethodEnum, + ModelFeatureEnum, + ModelStatusEnum, + ModelTypeEnum, +} from '../declarations' +import Popup from './popup' + +let mockLanguage = 'en_US' + +const mockSetShowAccountSettingModal = vi.hoisted(() => vi.fn()) +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowAccountSettingModal: mockSetShowAccountSettingModal, + }), +})) + +const mockSupportFunctionCall = vi.hoisted(() => vi.fn()) +vi.mock('@/utils/tool-call', () => ({ + supportFunctionCall: mockSupportFunctionCall, +})) + +const mockCloseActiveTooltip = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/tooltip/TooltipManager', () => ({ + tooltipManager: { + closeActiveTooltip: mockCloseActiveTooltip, + register: vi.fn(), + clear: vi.fn(), + }, +})) + +vi.mock('@/app/components/base/icons/src/vender/solid/general', () => ({ + XCircle: ({ onClick }: { onClick?: () => void }) => ( + + ), +})) + +const mockModel: DefaultModelResponse = { + model: 'gpt-4', + model_type: ModelTypeEnum.textGeneration, + provider: { + provider: 'openai', + icon_small: { en_US: '', zh_Hans: '' }, + }, +} + +const defaultProps = { + textGenerationDefaultModel: mockModel, + embeddingsDefaultModel: undefined, + rerankDefaultModel: undefined, + speech2textDefaultModel: undefined, + ttsDefaultModel: undefined, + notConfigured: false, + isLoading: false, +} + +describe('SystemModel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + }) + + it('should render settings button', () => { + render() + expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument() + }) + + it('should open modal when button is clicked', async () => { + render() + const button = screen.getByRole('button', { name: /system model settings/i }) + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText(/system reasoning model/i)).toBeInTheDocument() + }) + }) + + it('should disable button when loading', () => { + render() + expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled() + }) + + it('should close modal when cancel is clicked', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + await waitFor(() => { + expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument() + }) + }) + + it('should save selected models and show success feedback', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument() + }) + + const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' }) + selectorButtons.forEach(button => fireEvent.click(button)) + + fireEvent.click(screen.getByRole('button', { name: /save/i })) + + await waitFor(() => { + expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'Modified successfully', + }) + expect(mockUpdateModelList).toHaveBeenCalledTimes(5) + }) + }) + + it('should disable save when user is not workspace manager', async () => { + mockIsCurrentWorkspaceManager = false + render() + + fireEvent.click(screen.getByRole('button', { name: /system model settings/i })) + await waitFor(() => { + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/utils.spec.ts b/web/app/components/header/account-setting/model-provider-page/utils.spec.ts new file mode 100644 index 0000000000..9ed1663d0c --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/utils.spec.ts @@ -0,0 +1,238 @@ +import type { Mock } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { + deleteModelProvider, + setModelProvider, + validateModelLoadBalancingCredentials, + validateModelProvider, +} from '@/service/common' +import { ValidatedStatus } from '../key-validator/declarations' +import { + ConfigurationMethodEnum, + FormTypeEnum, + ModelTypeEnum, +} from './declarations' +import { + genModelNameFormSchema, + genModelTypeFormSchema, + modelTypeFormat, + removeCredentials, + saveCredentials, + savePredefinedLoadBalancingConfig, + sizeFormat, + validateCredentials, + validateLoadBalancingCredentials, +} from './utils' + +// Mock service/common functions +vi.mock('@/service/common', () => ({ + deleteModelProvider: vi.fn(), + setModelProvider: vi.fn(), + validateModelLoadBalancingCredentials: vi.fn(), + validateModelProvider: vi.fn(), +})) + +describe('utils', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + describe('sizeFormat', () => { + it('should format size less than 1000', () => { + expect(sizeFormat(500)).toBe('500') + }) + + it('should format size greater than 1000', () => { + expect(sizeFormat(1500)).toBe('1K') + }) + }) + + describe('modelTypeFormat', () => { + it('should format text embedding type', () => { + expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING') + }) + + it('should format other types', () => { + expect(modelTypeFormat(ModelTypeEnum.textGeneration)).toBe('LLM') + }) + }) + + describe('validateCredentials', () => { + it('should validate predefined credentials successfully', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateCredentials(true, 'provider', { key: 'value' }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials/validate', + body: { credentials: { key: 'value' } }, + }) + }) + + it('should validate custom credentials successfully', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/credentials/validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + + it('should handle validation failure', async () => { + (validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' }) + const result = await validateCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' }) + }) + + it('should handle exception', async () => { + (validateModelProvider as unknown as Mock).mockRejectedValue(new Error('network error')) + const result = await validateCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' }) + }) + }) + + describe('validateLoadBalancingCredentials', () => { + it('should validate load balancing credentials successfully', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateLoadBalancingCredentials(true, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/credentials-validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + it('should validate load balancing credentials successfully with id', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' }) + const result = await validateLoadBalancingCredentials(true, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }, 'id') + expect(result).toEqual({ status: ValidatedStatus.Success }) + expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/id/credentials-validate', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + }, + }) + }) + + it('should handle validation failure', async () => { + (validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' }) + const result = await validateLoadBalancingCredentials(true, 'provider', {}) + expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' }) + }) + }) + + describe('saveCredentials', () => { + it('should save predefined credentials', async () => { + await saveCredentials(true, 'provider', { __authorization_name__: 'name', key: 'value' }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials', + body: { + config_from: ConfigurationMethodEnum.predefinedModel, + credentials: { key: 'value' }, + load_balancing: undefined, + name: 'name', + }, + }) + }) + + it('should save custom credentials', async () => { + await saveCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + load_balancing: undefined, + }, + }) + }) + }) + + describe('savePredefinedLoadBalancingConfig', () => { + it('should save predefined load balancing config', async () => { + await savePredefinedLoadBalancingConfig('provider', { + __model_name: 'model', + __model_type: 'type', + key: 'value', + }) + expect(setModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + config_from: ConfigurationMethodEnum.predefinedModel, + model: 'model', + model_type: 'type', + credentials: { key: 'value' }, + load_balancing: undefined, + }, + }) + }) + }) + + describe('removeCredentials', () => { + it('should remove predefined credentials', async () => { + await removeCredentials(true, 'provider', {}, 'id') + expect(deleteModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/credentials', + body: { credential_id: 'id' }, + }) + }) + + it('should remove custom credentials', async () => { + await removeCredentials(false, 'provider', { + __model_name: 'model', + __model_type: 'type', + }) + expect(deleteModelProvider).toHaveBeenCalledWith({ + url: '/workspaces/current/model-providers/provider/models', + body: { + model: 'model', + model_type: 'type', + }, + }) + }) + }) + + describe('genModelTypeFormSchema', () => { + it('should generate form schema', () => { + const schema = genModelTypeFormSchema([ModelTypeEnum.textGeneration]) + expect(schema.type).toBe(FormTypeEnum.select) + expect(schema.variable).toBe('__model_type') + expect(schema.options[0].value).toBe(ModelTypeEnum.textGeneration) + }) + }) + + describe('genModelNameFormSchema', () => { + it('should generate form schema', () => { + const schema = genModelNameFormSchema() + expect(schema.type).toBe(FormTypeEnum.textInput) + expect(schema.variable).toBe('__model_name') + expect(schema.required).toBe(true) + }) + }) +})