test(web): add tests for model-provider-page files in header account-… (#32360)

This commit is contained in:
akashseth-ifp
2026-02-23 17:37:19 +05:30
committed by GitHub
parent 42af9d5438
commit a0244d1390
16 changed files with 2057 additions and 13 deletions

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import ModelBadge from './index'
describe('ModelBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for user-visible content.
describe('Rendering', () => {
it('should render provided text', () => {
render(<ModelBadge>Provider</ModelBadge>)
expect(screen.getByText(/provider/i)).toBeInTheDocument()
})
it('should render without text when children is null', () => {
const { container } = render(<ModelBadge>{null}</ModelBadge>)
expect(container.textContent).toBe('')
})
it('should render nested content', () => {
render(
<ModelBadge>
<span>Badge Label</span>
</ModelBadge>,
)
expect(screen.getByText(/badge label/i)).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,108 @@
import type { Model } from '../declarations'
import { render, screen } from '@testing-library/react'
import { Theme } from '@/types/app'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelIcon from './index'
type I18nText = {
en_US: string
zh_Hans: string
}
let mockTheme: Theme = Theme.light
let mockLanguage = 'en_US'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
})
const createModel = (overrides?: Partial<Model>): Model => ({
provider: 'test-provider',
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
label: createI18nText('Test Provider'),
models: [
{
model: 'test-model',
label: createI18nText('Test Model'),
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
},
],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = Theme.light
mockLanguage = 'en_US'
})
// Rendering
it('should render the light icon when icon_small is provided', () => {
const provider = createModel({
icon_small: createI18nText('light-only.png'),
icon_small_dark: undefined,
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png')
})
// Theme selection
it('should render the dark icon when theme is dark and icon_small_dark exists', () => {
mockTheme = Theme.dark
const provider = createModel({
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'dark.png')
})
// Provider override
it('should ignore icon_small for OpenAI models starting with "o"', () => {
const provider = createModel({
provider: 'openai',
icon_small: createI18nText('openai.png'),
})
render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
})
// Edge case
it('should render without an icon when provider is undefined', () => {
const { container } = render(<ModelIcon />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
})

View File

@@ -0,0 +1,447 @@
import type {
CredentialFormSchema,
CredentialFormSchemaBase,
CredentialFormSchemaNumberInput,
CredentialFormSchemaRadio,
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
} from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { FormTypeEnum } from '../declarations'
import Form from './Form'
type CustomSchema = Omit<CredentialFormSchemaBase, 'type'> & { type: 'custom-type' }
type MockVarPayload = { type: string }
type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum })
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => (
<button type="button" onClick={() => onSelect({ id: 'app-1' })}>Select App</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => (
<button type="button" onClick={() => setModel({ model: 'gpt-1', model_type: 'llm' })}>Select Model</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({
default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => (
<button type="button" onClick={() => onChange([{ id: 'tool-1' }])}>Select Tools</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => (
<div>
<button type="button" onClick={() => onSelect({ id: 'tool-1' })}>Select Tool</button>
<button type="button" onClick={onDelete}>Remove Tool</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => {
const allowed = filterVar ? filterVar({ type: 'text' }) : true
const blocked = filterVar ? filterVar({ type: 'image' }) : false
return (
<div>
<div>{allowed ? 'allowed' : 'blocked'}</div>
<div>{blocked ? 'allowed' : 'blocked'}</div>
<button type="button" onClick={() => onChange([{ name: 'var-1' }])}>Pick Variable</button>
</div>
)
},
}))
vi.mock('../../key-validator/ValidateStatus', () => ({
ValidatingTip: () => <div>Validating...</div>,
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createBaseSchema = (
type: FormTypeEnum,
overrides: Partial<CredentialFormSchemaBase> = {},
): CredentialFormSchemaBase => ({
name: overrides.variable ?? 'field',
variable: overrides.variable ?? 'field',
label: createI18n('Field'),
type,
required: false,
show_on: [],
...overrides,
})
const createTextSchema = (overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }) => ({
...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }),
placeholder: createI18n('Input'),
...overrides,
})
const createNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput>) => ({
...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }),
placeholder: createI18n('Number'),
min: 1,
max: 9,
...overrides,
})
const createRadioSchema = (overrides: Partial<CredentialFormSchemaRadio>) => ({
...createBaseSchema(FormTypeEnum.radio, { variable: overrides.variable ?? 'radio' }),
options: [
{ label: createI18n('Option A'), value: 'a', show_on: [] },
{ label: createI18n('Option B'), value: 'b', show_on: [] },
],
...overrides,
})
const createSelectSchema = (overrides: Partial<CredentialFormSchemaSelect>) => ({
...createBaseSchema(FormTypeEnum.select, { variable: overrides.variable ?? 'select' }),
placeholder: createI18n('Select one'),
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [] },
],
...overrides,
})
describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
describe('Rendering', () => {
it('should render visible fields and apply default values', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
required: true,
default: 'default-key',
}),
createTextSchema({
variable: 'secret',
type: FormTypeEnum.secretInput,
label: createI18n('Secret'),
placeholder: createI18n('Secret'),
}),
createNumberSchema({
variable: 'limit',
label: createI18n('Limit'),
placeholder: createI18n('Limit'),
default: '5',
}),
createTextSchema({
variable: 'hidden',
label: createI18n('Hidden'),
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = {
api_key: '',
secret: 'top-secret',
limit: '',
toggle: 'off',
}
render(
<Form
value={value}
onChange={vi.fn()}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
isShowDefaultValue
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('default-key')
expect(screen.getByPlaceholderText('Secret')).toHaveValue('top-secret')
expect(screen.getByPlaceholderText('Limit')).toHaveValue(5)
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
expect(screen.getAllByText('*')).toHaveLength(1)
})
})
// Interaction updates
describe('Interactions', () => {
it('should update values and clear dependent fields when a field changes', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
}),
createTextSchema({
variable: 'dependent',
label: createI18n('Dependent'),
default: 'reset',
}),
]
const value: FormValue = { api_key: 'old', dependent: 'keep' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating
validatedSuccess={false}
showOnVariableMap={{ api_key: ['dependent'] }}
isEditMode={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } })
expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', dependent: 'reset' })
expect(screen.getByText('Validating...')).toBeInTheDocument()
})
it('should render radio options based on show conditions and ignore edit-locked changes', () => {
const formSchemas: AnyFormSchema[] = [
createRadioSchema({
variable: 'region',
label: createI18n('Region'),
options: [
{ label: createI18n('US'), value: 'us', show_on: [] },
{ label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'hidden_region',
label: createI18n('Hidden Region'),
show_on: [{ variable: 'toggle', value: 'hidden' }],
options: [
{ label: createI18n('Hidden A'), value: 'a', show_on: [] },
],
}),
createRadioSchema({
variable: '__model_name',
label: createI18n('Locked'),
options: [
{ label: createI18n('Locked A'), value: 'a', show_on: [] },
],
}),
]
const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode
/>,
)
expect(screen.getByText('EU')).toBeInTheDocument()
expect(screen.queryByText('Hidden Region')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('EU'))
fireEvent.click(screen.getByText('Locked A'))
expect(onChange).toHaveBeenCalledWith({ region: 'eu', toggle: 'on', __model_name: 'a' })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should render select and checkbox fields and update checkbox value', () => {
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
variable: 'model',
label: createI18n('Model'),
placeholder: createI18n('Pick model'),
show_on: [{ variable: 'toggle', value: 'on' }],
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'agree',
type: FormTypeEnum.checkbox,
label: createI18n('Agree'),
options: [],
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = { model: 'a', agree: false, toggle: 'off' }
const onChange = vi.fn()
const { rerender } = render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.queryByText('Pick model')).not.toBeInTheDocument()
expect(screen.queryByText('Agree')).not.toBeInTheDocument()
rerender(
<Form
value={{ model: 'a', agree: false, toggle: 'on' }}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.getByText('Select A')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select A'))
fireEvent.click(screen.getByText('Select B'))
fireEvent.click(screen.getByText('True'))
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
})
it('should pass selected items from model and tool selectors to the form value', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'model_selector',
type: FormTypeEnum.modelSelector,
label: createI18n('Model Selector'),
}),
createTextSchema({
variable: 'tool_selector',
type: FormTypeEnum.toolSelector,
label: createI18n('Tool Selector'),
}),
createTextSchema({
variable: 'multi_tool',
type: FormTypeEnum.multiToolSelector,
label: createI18n('Multi Tool'),
tooltip: createI18n('Tips'),
}),
createTextSchema({
variable: 'app_selector',
type: FormTypeEnum.appSelector,
label: createI18n('App Selector'),
}),
]
const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
fireEvent.click(screen.getByText('Select Model'))
fireEvent.click(screen.getByText('Select Tool'))
fireEvent.click(screen.getByText('Remove Tool'))
fireEvent.click(screen.getByText('Select Tools'))
fireEvent.click(screen.getByText('Select App'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: { id: 'tool-1' },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: null,
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
multi_tool: [{ id: 'tool-1' }],
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
app_selector: { id: 'app-1', type: FormTypeEnum.appSelector },
}))
})
it('should render variable picker and custom render overrides', () => {
const formSchemas: Array<AnyFormSchema | CustomSchema> = [
createTextSchema({
variable: 'override',
label: createI18n('Override'),
type: FormTypeEnum.textInput,
}),
createTextSchema({
variable: 'any_var',
type: FormTypeEnum.any,
label: createI18n('Any Var'),
scope: 'text&audio',
}),
createTextSchema({
variable: 'any_without_scope',
type: FormTypeEnum.any,
label: createI18n('Any Without Scope'),
}),
{
...createTextSchema({
variable: 'custom_field',
label: createI18n('Custom Field'),
}),
type: 'custom-type',
},
]
const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' }
const onChange = vi.fn()
render(
<Form<CustomSchema>
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
fieldMoreInfo={() => <div>Extra Info</div>}
override={[[FormTypeEnum.textInput], () => <div>Override Field</div>]}
customRenderField={schema => (
<div>
Custom Render:
{schema.variable}
</div>
)}
/>,
)
expect(screen.getByText('Override Field')).toBeInTheDocument()
expect(screen.getByText(/Custom Render:.*custom_field/)).toBeInTheDocument()
expect(screen.getAllByText('allowed')).toHaveLength(3)
expect(screen.getAllByText('blocked')).toHaveLength(1)
fireEvent.click(screen.getAllByText('Pick Variable')[0])
expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' })
expect(screen.getAllByText('Extra Info')).toHaveLength(2)
})
})
})

View File

@@ -0,0 +1,96 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Input from './Input'
describe('Input', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
it('should render with the provided placeholder and value', () => {
render(
<Input
value="hello"
placeholder="API Key"
onChange={vi.fn()}
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello')
})
// User interaction
it('should call onChange when the user types', () => {
const onChange = vi.fn()
render(
<Input
placeholder="API Key"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } })
expect(onChange).toHaveBeenCalledWith('next')
})
// Edge cases: min/max enforcement
it('should clamp to the min value when the input is below min on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '1' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('2')
})
it('should clamp to the max value when the input is above max on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '8' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('6')
})
it('should keep the value when it is within the min/max range on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '4' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
})

View File

@@ -0,0 +1,353 @@
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelModalModeEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import ModelModal from './index'
type CredentialData = {
credentials: Record<string, unknown>
available_credentials: Credential[]
}
type ModelFormSchemas = {
formSchemas: CredentialFormSchema[]
formValues: Record<string, unknown>
modelNameAndTypeFormSchemas: CredentialFormSchema[]
modelNameAndTypeFormValues: Record<string, unknown>
}
const mockState = vi.hoisted(() => ({
isLoading: false,
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
doingAction: false,
deleteCredentialId: null as string | null,
isCurrentWorkspaceManager: true,
formSchemas: [] as CredentialFormSchema[],
formValues: {} as Record<string, unknown>,
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
modelNameAndTypeFormValues: {} as Record<string, unknown>,
}))
const mockHandlers = vi.hoisted(() => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
openConfirmDelete: vi.fn(),
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,
credentialData: mockState.credentialData,
}),
useAuth: () => ({
handleSaveCredential: mockHandlers.handleSaveCredential,
handleConfirmDelete: mockHandlers.handleConfirmDelete,
deleteCredentialId: mockState.deleteCredentialId,
closeConfirmDelete: mockHandlers.closeConfirmDelete,
openConfirmDelete: mockHandlers.openConfirmDelete,
doingAction: mockState.doingAction,
handleActiveCredential: mockHandlers.handleActiveCredential,
}),
useModelFormSchemas: (): ModelFormSchemas => ({
formSchemas: mockState.formSchemas,
formValues: mockState.formValues,
modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas,
modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
}))
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 => ({
provider: 'openai',
label: createI18n('OpenAI'),
help: {
title: createI18n('Help'),
url: createI18n('https://example.com'),
},
icon_small: createI18n('icon'),
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: createI18n('Model'), placeholder: createI18n('Model') },
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [
{
quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_unit: QuotaUnitEnum.times,
quota_limit: 0,
quota_used: 0,
last_used: 0,
is_valid: true,
},
],
},
allow_custom_token: true,
...overrides,
})
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
configurateMethod: ConfigurationMethodEnum.predefinedModel,
onCancel: vi.fn(),
onSave: vi.fn(),
onRemove: vi.fn(),
...overrides,
}
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.isLoading = false
mockState.credentialData = { credentials: {}, available_credentials: [] }
mockState.doingAction = false
mockState.deleteCredentialId = null
mockState.isCurrentWorkspaceManager = true
mockState.formSchemas = []
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
mockFormState.responses = []
})
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})

View File

@@ -0,0 +1,116 @@
import type { ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelName from './index'
let mockLocale = 'en-US'
vi.mock('#i18n', () => ({
useTranslation: () => ({
i18n: {
language: mockLocale,
},
}),
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4o',
label: {
en_US: 'English Model',
zh_Hans: 'Chinese Model',
},
model_type: ModelTypeEnum.textGeneration,
features: [],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
describe('ModelName', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
// Rendering scenarios for the model name label.
describe('rendering', () => {
it('should render the localized model label when translation exists', () => {
mockLocale = 'zh-Hans'
const modelItem = createModelItem()
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('Chinese Model')).toBeInTheDocument()
})
it('should fall back to en_US label when localized label is missing', () => {
mockLocale = 'fr-FR'
const modelItem = createModelItem({
label: {
en_US: 'English Only',
zh_Hans: 'Chinese Model',
},
})
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('English Only')).toBeInTheDocument()
})
it('should render nothing when modelItem is null', () => {
const { container } = render(<ModelName modelItem={null as unknown as ModelItem} />)
expect(container).toBeEmptyDOMElement()
})
})
// Badges that surface model metadata to the user.
describe('badges', () => {
it('should show model type, mode, and context size when enabled', () => {
const modelItem = createModelItem({
model_type: ModelTypeEnum.textEmbedding,
model_properties: {
mode: 'chat',
context_size: 2000,
},
})
render(
<ModelName
modelItem={modelItem}
showModelType
showMode
showContextSize
/>,
)
expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(screen.getByText('2K')).toBeInTheDocument()
})
it('should render feature labels when showFeaturesLabel is enabled', () => {
const modelItem = createModelItem({
features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio],
})
render(
<ModelName
modelItem={modelItem}
showFeatures
showFeaturesLabel
/>,
)
expect(screen.getByText('Vision')).toBeInTheDocument()
expect(screen.getByText('Audio')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,154 @@
import type { MouseEvent } from 'react'
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import AgentModelTrigger from './agent-model-trigger'
let modelProviders: ModelProvider[] = []
let pluginInfo: { latest_package_identifier: string } | null = null
let pluginLoading = false
let inModelList = true
const invalidateInstalledPluginList = vi.fn()
const handleOpenModal = vi.fn()
const updateModelProviders = vi.fn()
const updateModelList = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders,
}),
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateInstalledPluginList: () => invalidateInstalledPluginList,
useModelInList: () => ({ data: inModelList }),
usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }),
}))
vi.mock('../hooks', () => ({
useModelModalHandler: () => handleOpenModal,
useUpdateModelList: () => updateModelList,
useUpdateModelProviders: () => updateModelProviders,
}))
vi.mock('../model-icon', () => ({
default: () => <div>Icon</div>,
}))
vi.mock('./model-display', () => ({
default: () => <div>ModelDisplay</div>,
}))
vi.mock('./status-indicators', () => ({
default: () => <div>StatusIndicators</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent<HTMLButtonElement>) => void, onSuccess: () => void }) => (
<button
onClick={(event) => {
onClick(event)
onSuccess()
}}
>
Install Plugin
</button>
),
}))
describe('AgentModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviders = []
pluginInfo = null
pluginLoading = false
inModelList = true
})
it('should render loading state when plugin info is still fetching', () => {
pluginLoading = true
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render model actions for configured provider', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [{
quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_unit: QuotaUnitEnum.times,
quota_limit: 10,
quota_used: 1,
last_used: 1,
is_valid: true,
}],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('ModelDisplay')).toBeInTheDocument()
expect(screen.getByText('StatusIndicators')).toBeInTheDocument()
})
it('should support plugin installation flow when provider is missing', () => {
pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' }
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
scope={`${ModelTypeEnum.textGeneration},${ModelTypeEnum.tts}`}
/>,
)
fireEvent.click(screen.getByText('Install Plugin'))
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts)
expect(updateModelProviders).toHaveBeenCalledTimes(1)
expect(invalidateInstalledPluginList).toHaveBeenCalledTimes(1)
})
it('should show configuration action when provider requires setup', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
})
it('should render unconfigured state when model is not selected', () => {
render(<AgentModelTrigger />)
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,28 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { ConfigurationMethodEnum } from '../declarations'
import ConfigurationButton from './configuration-button'
describe('ConfigurationButton', () => {
it('should render and handle click', () => {
const handleOpenModal = vi.fn()
const modelProvider = { id: 1 }
render(
<ConfigurationButton
modelProvider={modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider']}
handleOpenModal={handleOpenModal}
/>,
)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleOpenModal).toHaveBeenCalledWith(
modelProvider,
ConfigurationMethodEnum.predefinedModel,
undefined,
)
})
})

View File

@@ -0,0 +1,273 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
let isRulesLoading = false
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
let currentModel: Record<string, unknown> | undefined = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
let activeTextGenerationModelList: Array<Record<string, unknown>> = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet,
}),
}))
vi.mock('@/service/use-common', () => ({
useModelParameterRules: () => ({
data: {
data: parameterRules,
},
isPending: isRulesLoading,
}),
}))
vi.mock('../hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentProvider,
currentModel,
activeTextGenerationModelList,
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
</div>
),
}))
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
}))
vi.mock('./presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
<button onClick={() => onSelect(1)}>Preset 1</button>
),
}))
vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
}
})
describe('ModelParameterModal', () => {
const defaultProps = {
isAdvancedMode: false,
modelId: 'gpt-3.5-turbo',
provider: 'openai',
setModel: vi.fn(),
completionParams: { temperature: 0.7 },
onCompletionParamsChange: vi.fn(),
hideDebugWithMultipleModel: false,
debugWithMultipleModel: false,
onDebugWithMultipleModelChange: vi.fn(),
readonly: false,
}
beforeEach(() => {
vi.clearAllMocks()
isAPIKeySet = true
isRulesLoading = false
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } }
currentModel = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
activeTextGenerationModelList = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
})
it('should render trigger and content', () => {
render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
})
it('should update params when changed and handle switch add/remove', () => {
render(<ModelParameterModal {...defaultProps} />)
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
})
fireEvent.click(screen.getByText('Remove'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({})
fireEvent.click(screen.getByText('Add'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 'assigned',
})
})
it('should handle preset selection', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
mode: 'chat',
features: ['vision', 'tool-call'],
})
})
})

View File

@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelDisplay from './model-display'
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
}))
describe('ModelDisplay', () => {
it('should render model name when model is present', () => {
const currentModel = { model: 'gpt-4' }
render(<ModelDisplay currentModel={currentModel} modelId="gpt-4" />)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
it('should render modelID when currentModel is missing', () => {
render(<ModelDisplay currentModel={null} modelId="unknown-model" />)
expect(screen.getByText('unknown-model')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,239 @@
import type { ModelParameterRule } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/radio', () => {
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
<div>
{children}
<button onClick={() => onChange(true)}>Select True</button>
<button onClick={() => onChange(false)}>Select False</button>
</div>
)
return { default: Radio }
})
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
<select onChange={e => onSelect({ value: e.target.value })}>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
<button onClick={() => onChange(!value)}>Switch</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
<input onChange={e => onChange(e.target.value.split(','))} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
type: 'float',
min: 0,
max: 1,
help: { en_US: 'Help text', zh_Hans: 'Help text' },
required: false,
...overrides,
})
const createProps = (overrides: {
parameterRule?: ModelParameterRule
value?: number | string | boolean | string[]
} = {}) => {
const onChange = vi.fn()
const onSwitch = vi.fn()
return {
parameterRule: createRule(),
value: 0.7,
onChange,
onSwitch,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render float input with slider', () => {
const props = createProps()
const { rerender } = render(<ParameterItem {...props} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.8' } })
expect(props.onChange).toHaveBeenCalledWith(0.8)
fireEvent.change(input, { target: { value: '1.4' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(input, { target: { value: '-0.2' } })
expect(props.onChange).toHaveBeenCalledWith(0)
const slider = screen.getByRole('slider')
fireEvent.change(slider, { target: { value: '2' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(slider, { target: { value: '-1' } })
expect(props.onChange).toHaveBeenCalledWith(0)
fireEvent.change(slider, { target: { value: '0.4' } })
expect(props.onChange).toHaveBeenCalledWith(0.4)
fireEvent.blur(input)
expect(input).toHaveValue(0.7)
const minBoundedProps = createProps({
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
value: 1.5,
})
rerender(<ParameterItem {...minBoundedProps} />)
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
})
it('should render boolean radio', () => {
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
render(<ParameterItem {...props} />)
expect(screen.getByText('True')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select False'))
expect(props.onChange).toHaveBeenCalledWith(false)
})
it('should render string input and select options', () => {
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
const { rerender } = render(<ParameterItem {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new' } })
expect(props.onChange).toHaveBeenCalledWith('new')
const selectProps = createProps({
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
value: 'opt1',
})
rerender(<ParameterItem {...selectProps} />)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: 'opt2' } })
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
})
it('should handle switch toggle', () => {
const props = createProps()
let view = render(<ParameterItem {...props} />)
fireEvent.click(screen.getByText('Switch'))
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
const intDefaultProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...intDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
const stringDefaultProps = createProps({
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...stringDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
const booleanDefaultProps = createProps({
parameterRule: createRule({ type: 'boolean', default: true }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...booleanDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
const tagDefaultProps = createProps({
parameterRule: createRule({ type: 'tag', default: ['one'] }),
value: undefined,
})
view.unmount()
const tagView = render(<ParameterItem {...tagDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
const zeroValueProps = createProps({
parameterRule: createRule({ type: 'float', default: 0.5 }),
value: 0,
})
tagView.unmount()
render(<ParameterItem {...zeroValueProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
})
it('should support text and tag parameter interactions', () => {
const textProps = createProps({
parameterRule: createRule({ type: 'text', name: 'prompt' }),
value: 'initial prompt',
})
const { rerender } = render(<ParameterItem {...textProps} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
const tagProps = createProps({
parameterRule: createRule({
type: 'tag',
name: 'tags',
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
}),
value: ['alpha'],
})
rerender(<ParameterItem {...tagProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
})
it('should support int parameters and unknown type fallback', () => {
const intProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
value: 100,
})
const { rerender } = render(<ParameterItem {...intProps} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
expect(intProps.onChange).toHaveBeenCalledWith(350)
const unknownTypeProps = createProps({
parameterRule: createRule({ type: 'unsupported' }),
value: 0.7,
})
rerender(<ParameterItem {...unknownTypeProps} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@@ -109,7 +109,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
const handleSwitch = (checked: boolean) => { const handleSwitch = (checked: boolean) => {
if (onSwitch) { if (onSwitch) {
const assignValue: ParameterValue = localValue || getDefaultValue() const assignValue: ParameterValue = localValue ?? getDefaultValue()
onSwitch(checked, assignValue) onSwitch(checked, assignValue)
} }
@@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
useEffect(() => { useEffect(() => {
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current) if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
numberInputRef.current.value = `${renderValue}` numberInputRef.current.value = `${renderValue}`
}, [value]) }, [value, parameterRule.type, renderValue])
const renderInput = () => { const renderInput = () => {
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float') const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
@@ -148,7 +148,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
)} )}
<input <input
ref={numberInputRef} ref={numberInputRef}
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none" className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
type="number" type="number"
max={parameterRule.max} max={parameterRule.max}
min={parameterRule.min} min={parameterRule.min}
@@ -175,7 +175,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
)} )}
<input <input
ref={numberInputRef} ref={numberInputRef}
className="system-sm-regular ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none" className="ml-4 block h-8 w-16 shrink-0 appearance-none rounded-lg bg-components-input-bg-normal pl-3 text-components-input-text-filled outline-none system-sm-regular"
type="number" type="number"
max={parameterRule.max} max={parameterRule.max}
min={parameterRule.min} min={parameterRule.min}
@@ -203,7 +203,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
if (parameterRule.type === 'string' && !parameterRule.options?.length) { if (parameterRule.type === 'string' && !parameterRule.options?.length) {
return ( return (
<input <input
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'system-sm-regular ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none')} className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
value={renderValue as string} value={renderValue as string}
onChange={handleStringInputChange} onChange={handleStringInputChange}
/> />
@@ -213,7 +213,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
if (parameterRule.type === 'text') { if (parameterRule.type === 'text') {
return ( return (
<textarea <textarea
className="system-sm-regular ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled" className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
value={renderValue as string} value={renderValue as string}
onChange={handleStringInputChange} onChange={handleStringInputChange}
/> />
@@ -265,7 +265,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
) )
} }
<div <div
className="system-xs-regular mr-0.5 truncate text-text-secondary" className="mr-0.5 truncate text-text-secondary system-xs-regular"
title={parameterRule.label[language] || parameterRule.label.en_US} title={parameterRule.label[language] || parameterRule.label.en_US}
> >
{parameterRule.label[language] || parameterRule.label.en_US} {parameterRule.label[language] || parameterRule.label.en_US}
@@ -284,7 +284,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
</div> </div>
{ {
parameterRule.type === 'tag' && ( parameterRule.type === 'tag' && (
<div className={cn(!isInWorkflow && 'w-[150px]', 'system-xs-regular text-text-tertiary')}> <div className={cn(!isInWorkflow && 'w-[150px]', 'text-text-tertiary system-xs-regular')}>
{parameterRule?.tagPlaceholder?.[language]} {parameterRule?.tagPlaceholder?.[language]}
</div> </div>
) )

View File

@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from './presets-parameter'
vi.mock('@/app/components/base/dropdown', () => ({
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
<div>
{renderTrigger(false)}
{items.map(item => (
<button key={item.value} onClick={() => onSelect(item)}>
{item.text}
</button>
))}
</div>
),
}))
describe('PresetsParameter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render presets and handle selection', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.model.tone.Creative'))
expect(onSelect).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import StatusIndicators from './status-indicators'
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
}))
const t = (key: string) => key
describe('StatusIndicators', () => {
beforeEach(() => {
vi.clearAllMocks()
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
it('should render nothing when model is available and enabled', () => {
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render warning states when provider model is disabled', () => {
const parentClick = vi.fn()
const { rerender } = render(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
expect(parentClick).not.toHaveBeenCalled()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>
</div>,
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
})
it('should render marketplace warning when provider is unavailable', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
inModelList={false}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,47 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import Trigger from './trigger'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
}),
}))
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>,
}))
describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
it('should render initialized state', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
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()
})
})

View File

@@ -4387,11 +4387,6 @@
"count": 1 "count": 1
} }
}, },
"app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 6
}
},
"app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": { "app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.tsx": {
"tailwindcss/enforce-consistent-class-order": { "tailwindcss/enforce-consistent-class-order": {
"count": 1 "count": 1