From a0244d1390b3a14245cc92cf711424e70dad2bb7 Mon Sep 17 00:00:00 2001 From: akashseth-ifp Date: Mon, 23 Feb 2026 17:37:19 +0530 Subject: [PATCH] =?UTF-8?q?test(web):=20add=20tests=20for=20model-provider?= =?UTF-8?q?-page=20files=20in=20header=20account-=E2=80=A6=20(#32360)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model-badge/index.spec.tsx | 33 ++ .../model-icon/index.spec.tsx | 108 +++++ .../model-modal/Form.spec.tsx | 447 ++++++++++++++++++ .../model-modal/Input.spec.tsx | 96 ++++ .../model-modal/index.spec.tsx | 353 ++++++++++++++ .../model-name/index.spec.tsx | 116 +++++ .../agent-model-trigger.spec.tsx | 154 ++++++ .../configuration-button.spec.tsx | 28 ++ .../model-parameter-modal/index.spec.tsx | 273 +++++++++++ .../model-display.spec.tsx | 20 + .../parameter-item.spec.tsx | 239 ++++++++++ .../model-parameter-modal/parameter-item.tsx | 16 +- .../presets-parameter.spec.tsx | 32 ++ .../status-indicators.spec.tsx | 103 ++++ .../model-parameter-modal/trigger.spec.tsx | 47 ++ web/eslint-suppressions.json | 5 - 16 files changed, 2057 insertions(+), 13 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.spec.tsx create mode 100644 web/app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.spec.tsx diff --git a/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx new file mode 100644 index 0000000000..bc68d9a94d --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-badge/index.spec.tsx @@ -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(Provider) + + expect(screen.getByText(/provider/i)).toBeInTheDocument() + }) + + it('should render without text when children is null', () => { + const { container } = render({null}) + + expect(container.textContent).toBe('') + }) + + it('should render nested content', () => { + render( + + Badge Label + , + ) + + expect(screen.getByText(/badge label/i)).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx new file mode 100644 index 0000000000..d397330159 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-icon/index.spec.tsx @@ -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: () => , +})) + +const createI18nText = (value: string): I18nText => ({ + en_US: value, + zh_Hans: value, +}) + +const createModel = (overrides?: Partial): 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() + + 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() + + 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() + + 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() + + expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument() + expect(container.firstChild).not.toBeNull() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx new file mode 100644 index 0000000000..572a2944f8 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Form.spec.tsx @@ -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 & { 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 }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({ + default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({ + default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => ( + + ), +})) + +vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({ + default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => ( +
+ + +
+ ), +})) + +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 ( +
+
{allowed ? 'allowed' : 'blocked'}
+
{blocked ? 'allowed' : 'blocked'}
+ +
+ ) + }, +})) + +vi.mock('../../key-validator/ValidateStatus', () => ({ + ValidatingTip: () =>
Validating...
, +})) + +const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) + +const createBaseSchema = ( + type: FormTypeEnum, + overrides: Partial = {}, +): CredentialFormSchemaBase => ({ + name: overrides.variable ?? 'field', + variable: overrides.variable ?? 'field', + label: createI18n('Field'), + type, + required: false, + show_on: [], + ...overrides, +}) + +const createTextSchema = (overrides: Partial & { type?: FormTypeEnum }) => ({ + ...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }), + placeholder: createI18n('Input'), + ...overrides, +}) + +const createNumberSchema = (overrides: Partial) => ({ + ...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }), + placeholder: createI18n('Number'), + min: 1, + max: 9, + ...overrides, +}) + +const createRadioSchema = (overrides: Partial) => ({ + ...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) => ({ + ...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( +
, + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.queryByText('Pick model')).not.toBeInTheDocument() + expect(screen.queryByText('Agree')).not.toBeInTheDocument() + + rerender( + , + ) + + 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( + , + ) + + 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 = [ + 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( + + value={value} + onChange={onChange} + formSchemas={formSchemas} + validating={false} + validatedSuccess={false} + showOnVariableMap={{}} + isEditMode={false} + fieldMoreInfo={() =>
Extra Info
} + override={[[FormTypeEnum.textInput], () =>
Override Field
]} + customRenderField={schema => ( +
+ Custom Render: + {schema.variable} +
+ )} + />, + ) + + 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) + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx new file mode 100644 index 0000000000..baea6732cb --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/Input.spec.tsx @@ -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( + , + ) + + expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello') + }) + + // User interaction + it('should call onChange when the user types', () => { + const onChange = vi.fn() + + render( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + const input = screen.getByPlaceholderText('Limit') + fireEvent.change(input, { target: { value: '4' } }) + fireEvent.blur(input) + + expect(onChange).not.toHaveBeenCalledWith('2') + expect(onChange).not.toHaveBeenCalledWith('6') + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx new file mode 100644 index 0000000000..376c128c89 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-modal/index.spec.tsx @@ -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 + available_credentials: Credential[] +} + +type ModelFormSchemas = { + formSchemas: CredentialFormSchema[] + formValues: Record + modelNameAndTypeFormSchemas: CredentialFormSchema[] + modelNameAndTypeFormValues: Record +} + +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, + modelNameAndTypeFormSchemas: [] as CredentialFormSchema[], + modelNameAndTypeFormValues: {} as Record, +})) + +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 +} +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 ( +
+ +
+ ) + }) + + return { default: AuthForm } +}) + +vi.mock('../model-auth', () => ({ + CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => ( +
+ + +
+ ), +})) + +const createI18n = (text: string) => ({ en_US: text, zh_Hans: text }) + +const createProvider = (overrides?: Partial): 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>) => { + const provider = createProvider() + const props = { + provider, + configurateMethod: ConfigurationMethodEnum.predefinedModel, + onCancel: vi.fn(), + onSave: vi.fn(), + onRemove: vi.fn(), + ...overrides, + } + const view = render() + 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() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx new file mode 100644 index 0000000000..9bc9b36653 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-name/index.spec.tsx @@ -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 => ({ + 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() + + 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() + + expect(screen.getByText('English Only')).toBeInTheDocument() + }) + + it('should render nothing when modelItem is null', () => { + const { container } = render() + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('Vision')).toBeInTheDocument() + expect(screen.getByText('Audio')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx new file mode 100644 index 0000000000..6b3a1724a1 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/agent-model-trigger.spec.tsx @@ -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: () =>
Icon
, +})) + +vi.mock('./model-display', () => ({ + default: () =>
ModelDisplay
, +})) + +vi.mock('./status-indicators', () => ({ + default: () =>
StatusIndicators
, +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({ + InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent) => void, onSuccess: () => void }) => ( + + ), +})) + +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( + , + ) + 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( + , + ) + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument() + }) + + it('should render unconfigured state when model is not selected', () => { + render() + expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx new file mode 100644 index 0000000000..622697c9a2 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/configuration-button.spec.tsx @@ -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( + ['modelProvider']} + handleOpenModal={handleOpenModal} + />, + ) + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(handleOpenModal).toHaveBeenCalledWith( + modelProvider, + ConfigurationMethodEnum.predefinedModel, + undefined, + ) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx new file mode 100644 index 0000000000..111af0b497 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.spec.tsx @@ -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 | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } } +let currentModel: Record | undefined = { + model: 'gpt-3.5-turbo', + status: 'active', + model_properties: { mode: 'chat' }, +} +let activeTextGenerationModelList: Array> = [ + { + 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 ( +
+
+ {children} +
+
+ ) + }, + PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => ( +
+ {children} +
+ ), + PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => ( +
+ {children} +
+ ), + } +}) + +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 }) => ( +
+ {parameterRule.label.en_US} + onChange(Number(e.target.value))} + /> + + +
+ ), +})) + +vi.mock('../model-selector', () => ({ + default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => ( +
+ Model Selector + +
+ ), +})) + +vi.mock('./presets-parameter', () => ({ + default: ({ onSelect }: { onSelect: (id: number) => void }) => ( + + ), +})) + +vi.mock('./trigger', () => ({ + default: () => , +})) + +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() + 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() + + 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() + + 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() + + fireEvent.click(screen.getByText('Preset 1')) + expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled() + }) + + it('should handle debug mode toggle', () => { + const { rerender } = render() + const toggle = screen.getByText(/debugAsMultipleModel/i) + fireEvent.click(toggle) + expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled() + + rerender() + expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument() + }) + it('should handle custom renderTrigger', () => { + const renderTrigger = vi.fn().mockReturnValue(
Custom Trigger
) + render() + + 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() + expect(screen.getByTestId('param-temperature')).toBeInTheDocument() + + rerender() + 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'], + }) + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx new file mode 100644 index 0000000000..ecee8c84e5 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/model-display.spec.tsx @@ -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 } }) =>
{modelItem.model}
, +})) + +describe('ModelDisplay', () => { + it('should render model name when model is present', () => { + const currentModel = { model: 'gpt-4' } + render() + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) + + it('should render modelID when currentModel is missing', () => { + render() + expect(screen.getByText('unknown-model')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx new file mode 100644 index 0000000000..bd4c902f54 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.spec.tsx @@ -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 }) => + Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => ( +
+ {children} + + +
+ ) + return { default: Radio } +}) + +vi.mock('@/app/components/base/select', () => ({ + SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => ( + + ), +})) + +vi.mock('@/app/components/base/slider', () => ({ + default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => ( + onChange(Number(e.target.value))} /> + ), +})) + +vi.mock('@/app/components/base/switch', () => ({ + default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => ( + + ), +})) + +vi.mock('@/app/components/base/tag-input', () => ({ + default: ({ onChange }: { onChange: (val: string[]) => void }) => ( + onChange(e.target.value.split(','))} /> + ), +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ popupContent }: { popupContent: React.ReactNode }) =>
{popupContent}
, +})) + +describe('ParameterItem', () => { + const createRule = (overrides: Partial = {}): 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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } }) + expect(intProps.onChange).toHaveBeenCalledWith(350) + + const unknownTypeProps = createProps({ + parameterRule: createRule({ type: 'unsupported' }), + value: 0.7, + }) + rerender() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx index f2c35c1823..8ae0b99159 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item.tsx @@ -109,7 +109,7 @@ const ParameterItem: FC = ({ 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 = ({ 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 = ({ )} = ({ )} = ({ if (parameterRule.type === 'string' && !parameterRule.options?.length) { return ( @@ -213,7 +213,7 @@ const ParameterItem: FC = ({ if (parameterRule.type === 'text') { return (