mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 01:45:13 +00:00
test(web): add tests for model-provider-page files in header account-… (#32360)
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -109,7 +109,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (onSwitch) {
|
||||
const assignValue: ParameterValue = localValue || getDefaultValue()
|
||||
const assignValue: ParameterValue = localValue ?? getDefaultValue()
|
||||
|
||||
onSwitch(checked, assignValue)
|
||||
}
|
||||
@@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
useEffect(() => {
|
||||
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
|
||||
numberInputRef.current.value = `${renderValue}`
|
||||
}, [value])
|
||||
}, [value, parameterRule.type, renderValue])
|
||||
|
||||
const renderInput = () => {
|
||||
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
|
||||
@@ -148,7 +148,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)}
|
||||
<input
|
||||
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"
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
@@ -175,7 +175,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)}
|
||||
<input
|
||||
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"
|
||||
max={parameterRule.max}
|
||||
min={parameterRule.min}
|
||||
@@ -203,7 +203,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
|
||||
return (
|
||||
<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}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
@@ -213,7 +213,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
if (parameterRule.type === 'text') {
|
||||
return (
|
||||
<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}
|
||||
onChange={handleStringInputChange}
|
||||
/>
|
||||
@@ -265,7 +265,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
)
|
||||
}
|
||||
<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}
|
||||
>
|
||||
{parameterRule.label[language] || parameterRule.label.en_US}
|
||||
@@ -284,7 +284,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
</div>
|
||||
{
|
||||
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]}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -4387,11 +4387,6 @@
|
||||
"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": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user