mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 09:28:04 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
This commit is contained in:
@ -1,15 +1,16 @@
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { AccountSettingTab } from '../constants'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useEffect } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { ACCOUNT_SETTING_TAB } from '../constants'
|
||||
import AccountSetting from '../index'
|
||||
|
||||
const mockResetModelProviderListExpanded = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
@ -46,65 +47,27 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/billing-page', () => ({
|
||||
default: () => <div data-testid="billing-page">Billing Page</div>,
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
|
||||
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
|
||||
useUpdateModelList: vi.fn(() => vi.fn()),
|
||||
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
|
||||
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/custom/custom-page', () => ({
|
||||
default: () => <div data-testid="custom-page">Custom Page</div>,
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
|
||||
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({
|
||||
default: () => <div data-testid="api-based-extension-page">API Based Extension Page</div>,
|
||||
vi.mock('@/service/use-datasource', () => ({
|
||||
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
|
||||
default: () => <div data-testid="data-source-page">Data Source Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/language-page', () => ({
|
||||
default: () => <div data-testid="language-page">Language Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/members-page', () => ({
|
||||
default: () => <div data-testid="members-page">Members Page</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
|
||||
default: ({ searchText }: { searchText: string }) => (
|
||||
<div data-testid="provider-page">
|
||||
{`provider-search:${searchText}`}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/menu-dialog', () => ({
|
||||
default: function MockMenuDialog({
|
||||
children,
|
||||
onClose,
|
||||
show,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
show?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape')
|
||||
onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return <div role="dialog">{children}</div>
|
||||
},
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
|
||||
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
const baseAppContextValue: AppContextValue = {
|
||||
@ -151,30 +114,37 @@ const baseAppContextValue: AppContextValue = {
|
||||
describe('AccountSetting', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnTabChange = vi.fn()
|
||||
const renderAccountSetting = (props?: {
|
||||
initialTab?: AccountSettingTab
|
||||
onCancel?: () => void
|
||||
onTabChange?: (tab: AccountSettingTab) => void
|
||||
}) => {
|
||||
const {
|
||||
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
onCancel = mockOnCancel,
|
||||
onTabChange = mockOnTabChange,
|
||||
} = props ?? {}
|
||||
|
||||
const renderAccountSetting = (props: Partial<ComponentProps<typeof AccountSetting>> = {}) => {
|
||||
const queryClient = new QueryClient()
|
||||
const mergedProps: ComponentProps<typeof AccountSetting> = {
|
||||
onCancel: mockOnCancel,
|
||||
...props,
|
||||
const StatefulAccountSetting = () => {
|
||||
const [activeTab, setActiveTab] = useState<AccountSettingTab>(initialTab)
|
||||
|
||||
return (
|
||||
<AccountSetting
|
||||
onCancelAction={onCancel}
|
||||
activeTab={activeTab}
|
||||
onTabChangeAction={(tab) => {
|
||||
setActiveTab(tab)
|
||||
onTabChange(tab)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const view = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountSetting {...mergedProps} />
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<StatefulAccountSetting />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return {
|
||||
...view,
|
||||
rerenderAccountSetting(nextProps: Partial<ComponentProps<typeof AccountSetting>>) {
|
||||
view.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AccountSetting {...mergedProps} {...nextProps} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -190,155 +160,171 @@ describe('AccountSetting', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the sidebar with correct menu items', () => {
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.provider')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.members')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.billing')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.dataSource')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.apiBasedExtension')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('custom.custom')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('members-page')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
|
||||
expect(screen.getByText('custom.custom')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should respect the activeTab prop', () => {
|
||||
renderAccountSetting({ activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
it('should respect the initial tab', () => {
|
||||
// Act
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
|
||||
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should sync the rendered page when activeTab changes', async () => {
|
||||
const { rerenderAccountSetting } = renderAccountSetting({
|
||||
activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE,
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
|
||||
|
||||
rerenderAccountSetting({
|
||||
activeTab: ACCOUNT_SETTING_TAB.CUSTOM,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('custom-page')).toBeInTheDocument()
|
||||
})
|
||||
// Assert
|
||||
// Check that the active item title is Data Source
|
||||
const titles = screen.getAllByText('common.settings.dataSource')
|
||||
// One in sidebar, one in header.
|
||||
expect(titles.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('should hide sidebar labels on mobile', () => {
|
||||
// Arrange
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
// On mobile, the labels should not be rendered as per the implementation
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter items for dataset operator', () => {
|
||||
// Arrange
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
...baseAppContextValue,
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.queryByTitle('common.settings.provider')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('common.settings.members')).not.toBeInTheDocument()
|
||||
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.settings.language')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing and custom tabs when disabled', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
})
|
||||
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
expect(screen.queryByTitle('common.settings.billing')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTitle('custom.custom')).not.toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should change active tab when clicking on a menu item', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should change active tab when clicking on menu item', () => {
|
||||
// Arrange
|
||||
renderAccountSetting({ onTabChange: mockOnTabChange })
|
||||
|
||||
await user.click(screen.getByTitle('common.settings.provider'))
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.settings.provider'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
|
||||
expect(screen.getByTestId('provider-page')).toBeInTheDocument()
|
||||
// Check for content from ModelProviderPage
|
||||
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['common.settings.billing', 'billing-page'],
|
||||
['common.settings.dataSource', 'data-source-page'],
|
||||
['common.settings.apiBasedExtension', 'api-based-extension-page'],
|
||||
['custom.custom', 'custom-page'],
|
||||
['common.settings.language', 'language-page'],
|
||||
['common.settings.members', 'members-page'],
|
||||
])('should render the "%s" page when its sidebar item is selected', async (menuTitle, pageTestId) => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should navigate through various tabs and show correct details', () => {
|
||||
// Act & Assert
|
||||
renderAccountSetting()
|
||||
|
||||
await user.click(screen.getByTitle(menuTitle))
|
||||
// Billing
|
||||
fireEvent.click(screen.getByText('common.settings.billing'))
|
||||
// Billing Page renders plansCommon.plan if data is loaded, or generic text.
|
||||
// Checking for title in header which is always there
|
||||
expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
|
||||
|
||||
expect(screen.getByTestId(pageTestId)).toBeInTheDocument()
|
||||
// Data Source
|
||||
fireEvent.click(screen.getByText('common.settings.dataSource'))
|
||||
expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
|
||||
|
||||
// API Based Extension
|
||||
fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
|
||||
expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
|
||||
|
||||
// Custom
|
||||
fireEvent.click(screen.getByText('custom.custom'))
|
||||
// Custom Page uses 'custom.custom' key as well.
|
||||
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
|
||||
|
||||
// Language
|
||||
fireEvent.click(screen.getAllByText('common.settings.language')[0])
|
||||
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
|
||||
|
||||
// Members
|
||||
fireEvent.click(screen.getAllByText('common.settings.members')[0])
|
||||
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call onCancel when clicking the close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
it('should call onCancel when clicking close button', () => {
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
const closeIcon = document.querySelector('.i-ri-close-line')
|
||||
const closeButton = closeIcon?.closest('button')
|
||||
expect(closeButton).not.toBeNull()
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
const closeControls = screen.getByText('ESC').parentElement
|
||||
|
||||
expect(closeControls).not.toBeNull()
|
||||
if (!closeControls)
|
||||
throw new Error('Close controls are missing')
|
||||
|
||||
await user.click(within(closeControls).getByRole('button'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when pressing Escape key', () => {
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update search value in the provider tab', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderAccountSetting()
|
||||
|
||||
await user.click(screen.getByTitle('common.settings.provider'))
|
||||
it('should update search value in provider tab', () => {
|
||||
// Arrange
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'test-search')
|
||||
fireEvent.change(input, { target: { value: 'test-search' } })
|
||||
|
||||
// Assert
|
||||
expect(input).toHaveValue('test-search')
|
||||
expect(screen.getByText('provider-search:test-search')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle scroll event in panel', () => {
|
||||
// Act
|
||||
renderAccountSetting()
|
||||
|
||||
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
|
||||
|
||||
// Assert
|
||||
expect(scrollContainer).toBeInTheDocument()
|
||||
if (scrollContainer) {
|
||||
// Scroll down
|
||||
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
|
||||
expect(scrollContainer).toHaveClass('overflow-y-auto')
|
||||
|
||||
// Scroll back up
|
||||
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
|
||||
}
|
||||
})
|
||||
|
||||
@ -36,7 +36,6 @@ import {
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from '../hooks'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from '../provider-added-card'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
@ -99,12 +98,17 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useExpandModelProviderList: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
const { useQuery, useQueryClient } = await import('@tanstack/react-query')
|
||||
const { getPayUrl } = await import('@/service/common')
|
||||
const { useProviderContext } = await import('@/context/provider-context')
|
||||
const { useModalContextSelector } = await import('@/context/modal-context')
|
||||
const { useEventEmitterContextContext } = await import('@/context/event-emitter')
|
||||
const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks')
|
||||
const { useExpandModelProviderList } = await import('../atoms')
|
||||
|
||||
describe('hooks', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -74,7 +74,7 @@ const mockDefaultModelState: {
|
||||
}
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultModel: () => mockDefaultModelState,
|
||||
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
||||
}))
|
||||
|
||||
vi.mock('../install-from-marketplace', () => ({
|
||||
|
||||
@ -64,6 +64,22 @@ describe('deriveModelStatus', () => {
|
||||
).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when model is missing and AI credits are exhausted without api key', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
undefined,
|
||||
createCredentialState({
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
}),
|
||||
),
|
||||
).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('should return configure-required when the model status is no-configure', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.noConfigure }), createCredentialState()),
|
||||
|
||||
@ -35,14 +35,21 @@ export const deriveModelStatus = (
|
||||
if (!modelId || !providerName)
|
||||
return 'empty'
|
||||
|
||||
if (!currentModelProvider || !currentModel)
|
||||
if (!currentModelProvider)
|
||||
return 'incompatible'
|
||||
|
||||
if (credentialState.priority === 'credits'
|
||||
const isCreditsExhaustedWithoutApiKey = credentialState.supportsCredits
|
||||
&& credentialState.isCreditsExhausted
|
||||
&& !credentialState.hasCredentials
|
||||
const isCreditsPriorityExhausted = credentialState.priority === 'credits'
|
||||
&& credentialState.supportsCredits
|
||||
&& credentialState.isCreditsExhausted) {
|
||||
&& credentialState.isCreditsExhausted
|
||||
|
||||
if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey)
|
||||
return 'credits-exhausted'
|
||||
}
|
||||
|
||||
if (!currentModel)
|
||||
return 'incompatible'
|
||||
|
||||
if (credentialState.variant === 'api-unavailable')
|
||||
return 'api-key-unavailable'
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import type * as React from 'react'
|
||||
import type { Credential, CredentialFormSchema, ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
@ -45,6 +44,15 @@ const mockHandlers = vi.hoisted(() => ({
|
||||
handleActiveCredential: vi.fn(),
|
||||
}))
|
||||
|
||||
type FormResponse = {
|
||||
isCheckValidated: boolean
|
||||
values: Record<string, unknown>
|
||||
}
|
||||
const mockFormState = vi.hoisted(() => ({
|
||||
responses: [] as FormResponse[],
|
||||
setFieldValue: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../model-auth/hooks', () => ({
|
||||
useCredentialData: () => ({
|
||||
isLoading: mockState.isLoading,
|
||||
@ -79,6 +87,36 @@ vi.mock('../../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
|
||||
const React = await import('react')
|
||||
const AuthForm = React.forwardRef(({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (field: string, value: string) => void
|
||||
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
|
||||
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
|
||||
}))
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
return { default: AuthForm }
|
||||
})
|
||||
|
||||
vi.mock('../../model-auth', () => ({
|
||||
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
|
||||
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
|
||||
|
||||
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
|
||||
@ -135,46 +173,6 @@ const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) =>
|
||||
return props
|
||||
}
|
||||
|
||||
const mockFormRef1 = {
|
||||
getFormValues: vi.fn(),
|
||||
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
|
||||
}
|
||||
|
||||
const mockFormRef2 = {
|
||||
getFormValues: vi.fn(),
|
||||
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
|
||||
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
|
||||
React.useImperativeHandle(ref, () => {
|
||||
// Return the mock depending on schemas passed (hacky but works for refs)
|
||||
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
|
||||
return mockFormRef1
|
||||
return mockFormRef2
|
||||
})
|
||||
return (
|
||||
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
|
||||
AuthForm Mock (
|
||||
{props.formSchemas.length}
|
||||
{' '}
|
||||
fields)
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../model-auth', () => ({
|
||||
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
|
||||
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
|
||||
Select Credential
|
||||
</button>
|
||||
),
|
||||
useAuth: vi.fn(),
|
||||
useCredentialData: vi.fn(),
|
||||
useModelFormSchemas: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ModelModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ModelParameterModal from '../index'
|
||||
|
||||
let isAPIKeySet = true
|
||||
@ -77,7 +77,7 @@ vi.mock('../parameter-item', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../model-selector', () => ({
|
||||
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
|
||||
<div data-testid="model-selector">
|
||||
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
|
||||
</div>
|
||||
@ -91,7 +91,7 @@ vi.mock('../presets-parameter', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../trigger', () => ({
|
||||
default: () => <button>Open Settings</button>,
|
||||
default: () => <button type="button">Open Settings</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Trigger from '../trigger'
|
||||
|
||||
const mockUseCredentialPanelState = vi.fn()
|
||||
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
@ -13,12 +14,30 @@ vi.mock('@/context/provider-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: () => mockUseCredentialPanelState(),
|
||||
}))
|
||||
|
||||
vi.mock('../../model-icon', () => ({
|
||||
default: () => <div data-testid="model-icon">Icon</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
|
||||
default: ({
|
||||
modelItem,
|
||||
showMode,
|
||||
showFeatures,
|
||||
}: {
|
||||
modelItem: { model: string }
|
||||
showMode?: boolean
|
||||
showFeatures?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<span>{modelItem.model}</span>
|
||||
{showMode && <span data-testid="model-name-mode">mode</span>}
|
||||
{showFeatures && <span data-testid="model-name-features">features</span>}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Trigger', () => {
|
||||
@ -40,14 +59,156 @@ describe('Trigger', () => {
|
||||
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render fallback model id when current model is missing', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="openai"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('gpt-4')).toBeInTheDocument()
|
||||
describe('Status badges', () => {
|
||||
it('should render credits exhausted badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable badge in non-workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credits exhausted badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'credits-exhausted',
|
||||
isCreditsExhausted: true,
|
||||
priority: 'credits',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render api unavailable badge in workflow mode', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'api-unavailable',
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
isInWorkflow
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when model is deprecated (currentModel missing)', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render credits exhausted badge when model is missing and AI credits are exhausted without api key', () => {
|
||||
mockUseCredentialPanelState.mockReturnValue({
|
||||
...activeCredentialState,
|
||||
variant: 'no-usage',
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
})
|
||||
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configure required badge when model status is no-configure', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'no-configure' } as typeof currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render disabled badge when model status is disabled', () => {
|
||||
render(
|
||||
<Trigger
|
||||
currentProvider={currentProvider}
|
||||
currentModel={{ ...currentModel, status: 'disabled' } as typeof currentModel}
|
||||
providerName="openai"
|
||||
modelId="gpt-4"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render incompatible badge when provider plugin is not installed', () => {
|
||||
render(
|
||||
<Trigger
|
||||
modelId="gpt-4"
|
||||
providerName="unknown-provider"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
|
||||
|
||||
@ -60,6 +60,17 @@ describe('deriveTriggerStatus', () => {
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, baseCredentialState)).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('returns credits-exhausted when currentModel is missing and AI credits are exhausted without api key', () => {
|
||||
const state: CredentialPanelState = {
|
||||
...baseCredentialState,
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
credentialName: undefined,
|
||||
}
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, state)).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('returns configure-required when model status is no-configure', () => {
|
||||
const model = { ...mockModel, status: ModelStatusEnum.noConfigure } as ModelItem
|
||||
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('configure-required')
|
||||
|
||||
@ -49,6 +49,8 @@ const Trigger: FC<TriggerProps> = ({
|
||||
const status = disabled ? 'incompatible' : derivedStatus
|
||||
const badgeKey = TRIGGER_STATUS_BADGE_I18N[status]
|
||||
const tooltipKey = TRIGGER_STATUS_TOOLTIP_I18N[status]
|
||||
const badgeLabel = badgeKey ? t(badgeKey, { ns: 'common', defaultValue: badgeKey }) : null
|
||||
const tooltipLabel = tooltipKey ? t(tooltipKey, { ns: 'common', defaultValue: tooltipKey }) : null
|
||||
const isActive = status === 'active'
|
||||
const iconProvider = currentProvider || modelProviders.find(item => item.provider === providerName)
|
||||
|
||||
@ -96,7 +98,7 @@ const Trigger: FC<TriggerProps> = ({
|
||||
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
|
||||
</div>
|
||||
{badgeKey && (
|
||||
tooltipKey
|
||||
tooltipLabel
|
||||
? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@ -105,14 +107,14 @@ const Trigger: FC<TriggerProps> = ({
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(badgeKey as 'modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent placement="top">
|
||||
{t(tooltipKey as 'modelProvider.selector.incompatibleTip', { ns: 'common' })}
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
@ -121,7 +123,7 @@ const Trigger: FC<TriggerProps> = ({
|
||||
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(badgeKey as 'modelProvider.selector.incompatible', { ns: 'common' })}
|
||||
{badgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import DeprecatedModelTrigger from '../deprecated-model-trigger'
|
||||
|
||||
vi.mock('../../model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
|
||||
}))
|
||||
|
||||
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(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
|
||||
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show deprecated tooltip when warn icon is hovered', async () => {
|
||||
const { container } = render(
|
||||
<DeprecatedModelTrigger
|
||||
modelName="gpt-deprecated"
|
||||
providerName="openai"
|
||||
showWarnIcon
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
|
||||
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not show deprecated tooltip when warn icon is disabled', async () => {
|
||||
render(
|
||||
<DeprecatedModelTrigger
|
||||
modelName="gpt-deprecated"
|
||||
providerName="openai"
|
||||
showWarnIcon={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,31 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import EmptyTrigger from '../empty-trigger'
|
||||
|
||||
describe('EmptyTrigger', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render configure model text', () => {
|
||||
render(<EmptyTrigger open={false} />)
|
||||
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// open=true: hover bg class present
|
||||
it('should apply hover background class when open is true', () => {
|
||||
// Act
|
||||
const { container } = render(<EmptyTrigger open={true} />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
|
||||
})
|
||||
|
||||
// className prop truthy: custom className appears on root
|
||||
it('should apply custom className when provided', () => {
|
||||
// Act
|
||||
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
|
||||
|
||||
// Assert
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,6 @@
|
||||
import type { Model, ModelItem } from '../../declarations'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { DefaultModel, Model, ModelItem } from '../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -7,16 +9,20 @@ import {
|
||||
} from '../../declarations'
|
||||
import ModelSelector from '../index'
|
||||
|
||||
vi.mock('../model-trigger', () => ({
|
||||
default: () => <div>model-trigger</div>,
|
||||
}))
|
||||
vi.mock('../model-selector-trigger', () => ({
|
||||
default: ({
|
||||
currentProvider,
|
||||
currentModel,
|
||||
defaultModel,
|
||||
}: { currentProvider?: Model, currentModel?: ModelItem, defaultModel?: DefaultModel }) => {
|
||||
if (currentProvider && currentModel)
|
||||
return <div>model-trigger</div>
|
||||
|
||||
vi.mock('../deprecated-model-trigger', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <div>{`deprecated:${modelName}`}</div>,
|
||||
}))
|
||||
if (defaultModel)
|
||||
return <div>{`deprecated:${defaultModel.model}`}</div>
|
||||
|
||||
vi.mock('../empty-trigger', () => ({
|
||||
default: () => <div>empty-trigger</div>,
|
||||
return <div>empty-trigger</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../popup', () => ({
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
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<typeof import('../../hooks')>('../../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => 'en_US',
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
|
||||
}))
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): 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> = {}): 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(
|
||||
<ModelTrigger
|
||||
open
|
||||
provider={makeModel()}
|
||||
model={makeModelItem()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show status tooltip content when model is not active', async () => {
|
||||
const { container } = render(
|
||||
<ModelTrigger
|
||||
open={false}
|
||||
provider={makeModel()}
|
||||
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<ModelTrigger
|
||||
open={false}
|
||||
provider={makeModel()}
|
||||
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
|
||||
readonly
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('GPT-4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,7 @@ import {
|
||||
ModelFeatureEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../../declarations'
|
||||
import PopupItem from '../popup-item'
|
||||
|
||||
@ -34,6 +35,36 @@ vi.mock('../../model-name', () => ({
|
||||
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../feature-icon', () => ({
|
||||
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tooltip', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
|
||||
useCredentialPanelState: mockCredentialPanelState,
|
||||
}))
|
||||
|
||||
vi.mock('../../provider-added-card/use-change-provider-priority', () => ({
|
||||
useChangeProviderPriority: () => ({
|
||||
isChangingPriority: false,
|
||||
handleChangePriority: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>,
|
||||
}))
|
||||
|
||||
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { Model, ModelItem } from '../../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
|
||||
import type { Model, ModelItem, ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelFeatureEnum,
|
||||
@ -23,6 +22,23 @@ vi.mock('@/utils/tool-call', () => ({
|
||||
supportFunctionCall: mockSupportFunctionCall,
|
||||
}))
|
||||
|
||||
type MockMarketplacePlugin = {
|
||||
plugin_id: string
|
||||
latest_package_identifier: string
|
||||
}
|
||||
|
||||
type MockContextProvider = Pick<ModelProvider, 'provider' | 'label' | 'icon_small' | 'icon_small_dark' | 'custom_configuration' | 'system_configuration'>
|
||||
|
||||
const mockMarketplacePlugins = vi.hoisted(() => ({
|
||||
current: [] as MockMarketplacePlugin[],
|
||||
isLoading: false,
|
||||
}))
|
||||
const mockContextModelProviders = vi.hoisted(() => ({
|
||||
current: [] as MockContextProvider[],
|
||||
}))
|
||||
const mockTrialModels = vi.hoisted(() => ({
|
||||
current: ['test-openai', 'test-anthropic'] as string[],
|
||||
}))
|
||||
vi.mock('../../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
|
||||
return {
|
||||
@ -35,6 +51,81 @@ vi.mock('../popup-item', () => ({
|
||||
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: { trial_models: mockTrialModels.current },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockTrialCredits = vi.hoisted(() => ({
|
||||
credits: 200,
|
||||
totalCredits: 200,
|
||||
isExhausted: false,
|
||||
isLoading: false,
|
||||
nextCreditResetDate: undefined as number | undefined,
|
||||
}))
|
||||
vi.mock('../../provider-added-card/use-trial-credits', () => ({
|
||||
useTrialCredits: () => mockTrialCredits,
|
||||
}))
|
||||
|
||||
vi.mock('../../provider-added-card/model-auth-dropdown/credits-exhausted-alert', () => ({
|
||||
default: ({ hasApiKeyFallback }: { hasApiKeyFallback: boolean }) => (
|
||||
<div data-testid="credits-exhausted-alert" data-has-api-key-fallback={String(hasApiKeyFallback)} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ theme: 'light' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return { ...actual, IS_CLOUD_EDITION: true }
|
||||
})
|
||||
|
||||
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/service/use-plugins', () => ({
|
||||
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
|
||||
}))
|
||||
|
||||
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
|
||||
default: () => ({ refreshPluginList: mockRefreshPluginList }),
|
||||
}))
|
||||
|
||||
const mockCheck = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
|
||||
default: () => ({ check: mockCheck }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
|
||||
return {
|
||||
...actual,
|
||||
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
|
||||
providerIconMap: {
|
||||
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
|
||||
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
|
||||
},
|
||||
modelNameMap: {
|
||||
'test-openai': 'TestOpenAI',
|
||||
'test-anthropic': 'TestAnthropic',
|
||||
},
|
||||
providerKeyToPluginId: {
|
||||
'test-openai': 'langgenius/openai',
|
||||
'test-anthropic': 'langgenius/anthropic',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'gpt-4',
|
||||
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
|
||||
@ -55,6 +146,20 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const makeContextProvider = (overrides: Partial<MockContextProvider> = {}): MockContextProvider => ({
|
||||
provider: 'test-openai',
|
||||
label: { en_US: 'Test OpenAI', zh_Hans: 'Test OpenAI' },
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
icon_small_dark: { en_US: '', zh_Hans: '' },
|
||||
custom_configuration: {
|
||||
status: 'no-configure',
|
||||
} as MockContextProvider['custom_configuration'],
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Popup', () => {
|
||||
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
@ -233,6 +338,200 @@ describe('Popup', () => {
|
||||
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
|
||||
fireEvent.change(input, { target: { value: 'gpt' } })
|
||||
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
it('should show installed marketplace providers without models when AI credits are available', () => {
|
||||
mockContextModelProviders.current = [makeContextProvider({
|
||||
provider: 'test-anthropic',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
})]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('test-anthropic')).toBeInTheDocument()
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide installed marketplace providers without models when AI credits are exhausted', () => {
|
||||
Object.assign(mockTrialCredits, {
|
||||
credits: 0,
|
||||
totalCredits: 200,
|
||||
isExhausted: true,
|
||||
})
|
||||
mockContextModelProviders.current = [makeContextProvider({
|
||||
provider: 'test-anthropic',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
} as MockContextProvider['system_configuration'],
|
||||
})]
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle marketplace section collapse', () => {
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
|
||||
|
||||
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
|
||||
|
||||
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should install plugin when clicking install button', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle install failure gracefully', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should not crash, install buttons should still be available
|
||||
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should run checkTaskStatus when not all_installed', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
|
||||
mockCheck.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
|
||||
fireEvent.click(installButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCheck).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
|
||||
})
|
||||
})
|
||||
expect(mockRefreshPluginList).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip install requests when marketplace plugins are still loading', async () => {
|
||||
mockMarketplacePlugins.current = [
|
||||
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
|
||||
]
|
||||
mockMarketplacePlugins.isLoading = true
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip install requests when the marketplace plugin cannot be found', async () => {
|
||||
mockMarketplacePlugins.current = []
|
||||
|
||||
render(
|
||||
<Popup
|
||||
modelList={[]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should sort the selected provider to the top when a default model is provided', () => {
|
||||
render(
|
||||
<Popup
|
||||
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
|
||||
modelList={[
|
||||
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
|
||||
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
onHide={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const providerLabels = screen.getAllByText(/openai|anthropic/)
|
||||
expect(providerLabels[0]).toHaveTextContent('anthropic')
|
||||
})
|
||||
})
|
||||
|
||||
@ -64,6 +64,8 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
const isDisabled = status !== 'active' && status !== 'empty'
|
||||
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[status]
|
||||
const tooltipI18nKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status]
|
||||
const statusLabel = statusI18nKey ? t(statusI18nKey, { ns: 'common', defaultValue: statusI18nKey }) : null
|
||||
const tooltipLabel = tooltipI18nKey ? t(tooltipI18nKey, { ns: 'common', defaultValue: tooltipI18nKey }) : null
|
||||
const isCreditsExhausted = status === 'credits-exhausted'
|
||||
const shouldShowModelMeta = status === 'active'
|
||||
|
||||
@ -118,7 +120,7 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
{isSelected && !readonly && !isActive && statusI18nKey && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
disabled={!tooltipI18nKey}
|
||||
disabled={!tooltipLabel}
|
||||
render={(
|
||||
<div
|
||||
className={cn(
|
||||
@ -128,14 +130,14 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
|
||||
>
|
||||
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
|
||||
<span className="whitespace-nowrap text-text-warning system-xs-medium">
|
||||
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{tooltipI18nKey && (
|
||||
{tooltipLabel && (
|
||||
<TooltipContent placement="top">
|
||||
{t(tooltipI18nKey as 'modelProvider.selector.incompatibleTip', { ns: 'common' })}
|
||||
{tooltipLabel}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
@ -19,7 +19,11 @@ import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelFeatureEnum,
|
||||
ModelStatusEnum,
|
||||
} from '../declarations'
|
||||
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
|
||||
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
|
||||
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
|
||||
@ -58,6 +62,19 @@ const Popup: FC<PopupProps> = ({
|
||||
const { isExhausted: isCreditsExhausted } = useTrialCredits()
|
||||
const { data: systemFeatures } = useSystemFeaturesQuery()
|
||||
const trialModels = systemFeatures?.trial_models
|
||||
const installedProviderMap = useMemo(() => new Map(
|
||||
modelProviders.map(provider => [provider.provider, provider]),
|
||||
), [modelProviders])
|
||||
const aiCreditVisibleProviders = useMemo(() => {
|
||||
if (isCreditsExhausted)
|
||||
return new Set<string>()
|
||||
|
||||
return new Set(
|
||||
modelProviders
|
||||
.filter(provider => providerSupportsCredits(provider, trialModels))
|
||||
.map(provider => provider.provider),
|
||||
)
|
||||
}, [isCreditsExhausted, modelProviders, trialModels])
|
||||
const showCreditsExhaustedAlert = isCreditsExhausted
|
||||
&& modelProviders.some(provider => providerSupportsCredits(provider, trialModels))
|
||||
const hasApiKeyFallback = modelProviders.some((provider) => {
|
||||
@ -92,18 +109,31 @@ const Popup: FC<PopupProps> = ({
|
||||
const installedModelList = useMemo(() => {
|
||||
const modelMap = new Map(modelList.map(model => [model.provider, model]))
|
||||
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
|
||||
const installedProvider = modelProviders.find(provider => provider.provider === providerKey)
|
||||
const installedProvider = installedProviderMap.get(providerKey)
|
||||
|
||||
if (!installedProvider)
|
||||
return []
|
||||
|
||||
const matchedModel = modelMap.get(providerKey)
|
||||
return matchedModel ? [matchedModel] : []
|
||||
if (matchedModel)
|
||||
return [matchedModel]
|
||||
|
||||
if (!aiCreditVisibleProviders.has(providerKey))
|
||||
return []
|
||||
|
||||
return [{
|
||||
provider: installedProvider.provider,
|
||||
icon_small: installedProvider.icon_small,
|
||||
icon_small_dark: installedProvider.icon_small_dark,
|
||||
label: installedProvider.label,
|
||||
models: [],
|
||||
status: ModelStatusEnum.active,
|
||||
}]
|
||||
})
|
||||
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
|
||||
|
||||
return [...installedMarketplaceModels, ...otherModels]
|
||||
}, [modelList, modelProviders])
|
||||
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
|
||||
|
||||
const filteredModelList = useMemo(() => {
|
||||
const filtered = installedModelList.map((model) => {
|
||||
@ -128,7 +158,7 @@ const Popup: FC<PopupProps> = ({
|
||||
return modelItem.features?.includes(feature) ?? false
|
||||
})
|
||||
})
|
||||
if (!matchesProviderSearch || filteredModels.length === 0)
|
||||
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
|
||||
return null
|
||||
|
||||
return { ...model, models: filteredModels }
|
||||
@ -143,7 +173,7 @@ const Popup: FC<PopupProps> = ({
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
|
||||
}, [aiCreditVisibleProviders, defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
|
||||
|
||||
const marketplaceProviders = useMemo(() => {
|
||||
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AddModelButton from '../add-model-button'
|
||||
|
||||
describe('AddModelButton', () => {
|
||||
it('should render button with text', () => {
|
||||
render(<AddModelButton onClick={vi.fn()} />)
|
||||
expect(screen.getByText('common.modelProvider.addModel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<AddModelButton onClick={handleClick} />)
|
||||
const button = screen.getByText('common.modelProvider.addModel')
|
||||
fireEvent.click(button)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,8 +1,12 @@
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { changeModelProviderPriority } from '@/service/common'
|
||||
import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '../../declarations'
|
||||
import CredentialPanel from '../credential-panel'
|
||||
|
||||
const mockEventEmitter = { emit: vi.fn() }
|
||||
@ -58,18 +62,22 @@ vi.mock('../../hooks', () => ({
|
||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||
}))
|
||||
|
||||
vi.mock('../priority-selector', () => ({
|
||||
default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => (
|
||||
<button data-testid="priority-selector" onClick={() => onSelect('custom')}>
|
||||
Priority Selector
|
||||
{' '}
|
||||
{value}
|
||||
</button>
|
||||
vi.mock('../use-trial-credits', () => ({
|
||||
useTrialCredits: () => mockTrialCredits,
|
||||
}))
|
||||
|
||||
vi.mock('../model-auth-dropdown', () => ({
|
||||
default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => (
|
||||
<div data-testid="model-auth-dropdown" data-variant={state.variant}>
|
||||
<button data-testid="change-priority-btn" onClick={() => onChangePriority('custom')}>
|
||||
Change Priority
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../priority-use-tip', () => ({
|
||||
default: () => <div data-testid="priority-use-tip">Priority Tip</div>,
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: ({ color }: { color: string }) => <div data-testid="indicator" data-color={color} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fetchModelProviderModelList } from '@/service/common'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useExpandModelProviderList } from '../../atoms'
|
||||
import { ConfigurationMethodEnum } from '../../declarations'
|
||||
import ProviderAddedCard from '../index'
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { disableModel, enableModel } from '@/service/common'
|
||||
import { ModelStatusEnum } from '../../declarations'
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import QuotaPanel from '../quota-panel'
|
||||
|
||||
let mockWorkspace = {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { DefaultModelResponse } from '../../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { ModelTypeEnum } from '../../declarations'
|
||||
import SystemModel from '../index'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user