mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 20:22:39 +08:00
test: add unit tests for configuration components including instruction editor, prompt toast, and variable management
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
buildPromptVariableFromExternalDataTool,
|
||||
buildPromptVariableFromInput,
|
||||
createPromptVariablesWithIds,
|
||||
getDuplicateError,
|
||||
toInputVar,
|
||||
} from '../helpers'
|
||||
|
||||
const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => ({
|
||||
key: 'var_1',
|
||||
name: 'Variable 1',
|
||||
required: false,
|
||||
type: 'string',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
label: 'Variable 1',
|
||||
required: false,
|
||||
type: InputVarType.textInput,
|
||||
variable: 'var_1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createExternalDataTool = (overrides: Partial<ExternalDataTool> = {}): ExternalDataTool => ({
|
||||
config: { region: 'us' },
|
||||
enabled: true,
|
||||
icon: 'icon',
|
||||
icon_background: '#000',
|
||||
label: 'External Tool',
|
||||
type: 'api',
|
||||
variable: 'external_tool',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('config-var/helpers', () => {
|
||||
it('should convert prompt variables into input vars', () => {
|
||||
expect(toInputVar(createPromptVariable())).toEqual(expect.objectContaining({
|
||||
label: 'Variable 1',
|
||||
required: false,
|
||||
type: InputVarType.textInput,
|
||||
variable: 'var_1',
|
||||
}))
|
||||
|
||||
expect(toInputVar(createPromptVariable({
|
||||
required: undefined,
|
||||
type: 'select',
|
||||
}))).toEqual(expect.objectContaining({
|
||||
required: false,
|
||||
type: 'select',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should build prompt variables from input vars', () => {
|
||||
expect(buildPromptVariableFromInput(createInputVar())).toEqual(expect.objectContaining({
|
||||
key: 'var_1',
|
||||
name: 'Variable 1',
|
||||
type: 'string',
|
||||
}))
|
||||
|
||||
expect(buildPromptVariableFromInput(createInputVar({
|
||||
options: ['One'],
|
||||
type: InputVarType.select,
|
||||
}))).toEqual(expect.objectContaining({
|
||||
options: ['One'],
|
||||
type: InputVarType.select,
|
||||
}))
|
||||
|
||||
expect(buildPromptVariableFromInput(createInputVar({
|
||||
options: ['One'],
|
||||
type: InputVarType.number,
|
||||
}))).not.toHaveProperty('options')
|
||||
})
|
||||
|
||||
it('should detect duplicate keys and labels', () => {
|
||||
expect(getDuplicateError([
|
||||
createPromptVariable({ key: 'same', name: 'First' }),
|
||||
createPromptVariable({ key: 'same', name: 'Second' }),
|
||||
])).toEqual({
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.varName',
|
||||
})
|
||||
|
||||
expect(getDuplicateError([
|
||||
createPromptVariable({ key: 'first', name: 'Same' }),
|
||||
createPromptVariable({ key: 'second', name: 'Same' }),
|
||||
])).toEqual({
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.labelName',
|
||||
})
|
||||
|
||||
expect(getDuplicateError([
|
||||
createPromptVariable({ key: 'first', name: 'First' }),
|
||||
createPromptVariable({ key: 'second', name: 'Second' }),
|
||||
])).toBeNull()
|
||||
})
|
||||
|
||||
it('should build prompt variables from external data tools and assign ids', () => {
|
||||
const tool = createExternalDataTool()
|
||||
expect(buildPromptVariableFromExternalDataTool(tool, true)).toEqual(expect.objectContaining({
|
||||
config: { region: 'us' },
|
||||
enabled: true,
|
||||
key: 'external_tool',
|
||||
name: 'External Tool',
|
||||
required: true,
|
||||
type: 'api',
|
||||
}))
|
||||
|
||||
expect(createPromptVariablesWithIds([
|
||||
createPromptVariable({ key: 'first' }),
|
||||
createPromptVariable({ key: 'second' }),
|
||||
])).toEqual([
|
||||
{ id: 'first', variable: expect.objectContaining({ key: 'first' }) },
|
||||
{ id: 'second', variable: expect.objectContaining({ key: 'second' }) },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InputTypeIcon from '../input-type-icon'
|
||||
|
||||
describe('InputTypeIcon', () => {
|
||||
it('should render icons for supported variable types', () => {
|
||||
const { container, rerender } = render(<InputTypeIcon className="text-test" type="string" />)
|
||||
expect(container.querySelector('svg')).toBeTruthy()
|
||||
|
||||
rerender(<InputTypeIcon className="text-test" type="select" />)
|
||||
expect(container.querySelector('svg')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ModalFoot from '../modal-foot'
|
||||
|
||||
describe('ModalFoot', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render cancel and save actions', () => {
|
||||
render(<ModalFoot onCancel={vi.fn()} onConfirm={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger callbacks when action buttons are clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(<ModalFoot onCancel={onCancel} onConfirm={onConfirm} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SelectVarType from '../select-var-type'
|
||||
|
||||
describe('SelectVarType', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const clickAddTrigger = () => {
|
||||
const label = screen.getByText('common.operation.add')
|
||||
const trigger = label.closest('div.cursor-pointer')
|
||||
expect(trigger).not.toBeNull()
|
||||
fireEvent.click(trigger!)
|
||||
}
|
||||
|
||||
it('should open the type list from the add trigger', async () => {
|
||||
render(<SelectVarType onChange={vi.fn()} />)
|
||||
|
||||
clickAddTrigger()
|
||||
|
||||
expect(await screen.findByText('appDebug.variableConfig.string')).toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.variableConfig.apiBasedVar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should emit the selected type and close the list', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<SelectVarType onChange={onChange} />)
|
||||
|
||||
clickAddTrigger()
|
||||
fireEvent.click(await screen.findByText('appDebug.variableConfig.apiBasedVar'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('api')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appDebug.variableConfig.string')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import VarItem from '../var-item'
|
||||
|
||||
describe('VarItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render variable metadata and required badge', () => {
|
||||
render(
|
||||
<VarItem
|
||||
label="Customer Name"
|
||||
name="customer_name"
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
required
|
||||
type="string"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTitle('customer_name · Customer Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('required')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger edit and remove callbacks', () => {
|
||||
const onEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
|
||||
const { container } = render(
|
||||
<VarItem
|
||||
label="Customer Name"
|
||||
name="customer_name"
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
required={false}
|
||||
type="string"
|
||||
/>,
|
||||
)
|
||||
|
||||
const actionButtons = container.querySelectorAll('div.h-6.w-6')
|
||||
fireEvent.click(actionButtons[0])
|
||||
fireEvent.click(screen.getByTestId('var-item-delete-btn'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(onRemove).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should highlight destructive state while hovering the delete action', () => {
|
||||
const { container } = render(
|
||||
<VarItem
|
||||
label="Customer Name"
|
||||
name="customer_name"
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
required={false}
|
||||
type="string"
|
||||
/>,
|
||||
)
|
||||
|
||||
const item = container.firstElementChild as HTMLElement
|
||||
const deleteButton = screen.getByTestId('var-item-delete-btn')
|
||||
|
||||
fireEvent.mouseOver(deleteButton)
|
||||
expect(item.className).toContain('border-state-destructive-border')
|
||||
|
||||
fireEvent.mouseLeave(deleteButton)
|
||||
expect(item.className).not.toContain('border-state-destructive-border')
|
||||
})
|
||||
})
|
||||
82
web/app/components/app/configuration/config-var/helpers.ts
Normal file
82
web/app/components/app/configuration/config-var/helpers.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { hasDuplicateStr } from '@/utils/var'
|
||||
|
||||
export type ExternalDataToolParams = {
|
||||
key: string
|
||||
type: string
|
||||
index: number
|
||||
name: string
|
||||
config?: PromptVariable['config']
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
}
|
||||
|
||||
export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL'
|
||||
|
||||
export const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox'])
|
||||
|
||||
export const toInputVar = (item: PromptVariable): InputVar => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
type: (item.type === 'string' ? InputVarType.textInput : item.type) as InputVarType,
|
||||
required: item.required ?? false,
|
||||
})
|
||||
|
||||
export const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => {
|
||||
const { variable, label, type, ...rest } = payload
|
||||
const nextType = type === InputVarType.textInput ? 'string' : type
|
||||
const nextItem: PromptVariable = {
|
||||
...rest,
|
||||
type: nextType,
|
||||
key: variable,
|
||||
name: label as string,
|
||||
}
|
||||
|
||||
if (payload.type !== InputVarType.select)
|
||||
delete nextItem.options
|
||||
|
||||
return nextItem
|
||||
}
|
||||
|
||||
export const getDuplicateError = (list: PromptVariable[]) => {
|
||||
if (hasDuplicateStr(list.map(item => item.key))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.varName',
|
||||
}
|
||||
}
|
||||
if (hasDuplicateStr(list.map(item => item.name as string))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.labelName',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const buildPromptVariableFromExternalDataTool = (
|
||||
externalDataTool: ExternalDataTool,
|
||||
required: boolean,
|
||||
): PromptVariable => ({
|
||||
key: externalDataTool.variable as string,
|
||||
name: externalDataTool.label as string,
|
||||
enabled: externalDataTool.enabled,
|
||||
type: externalDataTool.type as string,
|
||||
config: externalDataTool.config,
|
||||
required,
|
||||
icon: externalDataTool.icon,
|
||||
icon_background: externalDataTool.icon_background,
|
||||
})
|
||||
|
||||
export const createPromptVariablesWithIds = (promptVariables: PromptVariable[]) => {
|
||||
return promptVariables.map((item) => {
|
||||
return {
|
||||
id: item.key,
|
||||
variable: { ...item },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IConfigVarProps } from './index'
|
||||
import type DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import DebugConfigurationContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import ConfigVar, { ADD_EXTERNAL_DATA_TOOL } from './index'
|
||||
|
||||
const mockUseContext = vi.fn()
|
||||
|
||||
vi.mock('use-context-selector', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('use-context-selector')>()
|
||||
return {
|
||||
...actual,
|
||||
useContext: (context: unknown) => mockUseContext(context),
|
||||
}
|
||||
})
|
||||
|
||||
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
|
||||
|
||||
const setShowExternalDataToolModal = vi.fn()
|
||||
@@ -97,11 +107,8 @@ const renderConfigVar = (props: Partial<IConfigVarProps> = {}, debugOverrides: P
|
||||
...props,
|
||||
}
|
||||
|
||||
return render(
|
||||
<DebugConfigurationContext.Provider value={createDebugConfigValue(debugOverrides)}>
|
||||
<ConfigVar {...mergedProps} />
|
||||
</DebugConfigurationContext.Provider>,
|
||||
)
|
||||
mockUseContext.mockReturnValue(createDebugConfigValue(debugOverrides))
|
||||
return render(<ConfigVar {...mergedProps} />)
|
||||
}
|
||||
|
||||
describe('ConfigVar', () => {
|
||||
@@ -143,6 +150,19 @@ describe('ConfigVar', () => {
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar])
|
||||
})
|
||||
|
||||
it('should hide editing affordances in readonly mode', () => {
|
||||
renderConfigVar({
|
||||
promptVariables: [createPromptVariable({ key: 'readonly', name: 'Readonly' })],
|
||||
readonly: true,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('readonly · Readonly')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
expect(screen.queryByText('common.operation.add')).not.toBeInTheDocument()
|
||||
expect(itemContainer!.className).toContain('cursor-not-allowed')
|
||||
})
|
||||
})
|
||||
|
||||
// Variable creation flows using the add menu.
|
||||
@@ -209,6 +229,85 @@ describe('ConfigVar', () => {
|
||||
expect(addedVariables[1].type).toBe('api')
|
||||
expect(onPromptVariablesChange).toHaveBeenLastCalledWith([existingVar])
|
||||
})
|
||||
|
||||
it('should validate and save external data tool edits from the modal callback', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const existingVar = createPromptVariable({
|
||||
config: { region: 'us' },
|
||||
key: 'api_var',
|
||||
name: 'API Var',
|
||||
type: 'api',
|
||||
})
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [existingVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('api_var · API Var')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
|
||||
expect(modalState.onValidateBeforeSaveCallback?.({
|
||||
label: 'Updated API Var',
|
||||
type: 'api',
|
||||
variable: 'updated_api_var',
|
||||
})).toBe(true)
|
||||
|
||||
act(() => {
|
||||
modalState.onSaveCallback?.({
|
||||
config: { region: 'eu' },
|
||||
enabled: false,
|
||||
icon: 'updated-icon',
|
||||
icon_background: '#fff',
|
||||
label: 'Updated API Var',
|
||||
type: 'api',
|
||||
variable: 'updated_api_var',
|
||||
})
|
||||
})
|
||||
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
config: { region: 'eu' },
|
||||
enabled: false,
|
||||
icon: 'updated-icon',
|
||||
icon_background: '#fff',
|
||||
key: 'updated_api_var',
|
||||
name: 'Updated API Var',
|
||||
required: false,
|
||||
type: 'api',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should reject duplicated external data tool keys before saving', async () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const existingVar = createPromptVariable({ key: 'existing', name: 'Existing' })
|
||||
const apiVar = createPromptVariable({ key: 'api_var', name: 'API Var', type: 'api' })
|
||||
|
||||
renderConfigVar({
|
||||
promptVariables: [existingVar, apiVar],
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const item = screen.getByTitle('api_var · API Var')
|
||||
const itemContainer = item.closest('div.group')
|
||||
expect(itemContainer).not.toBeNull()
|
||||
const actionButtons = itemContainer!.querySelectorAll('div.h-6.w-6')
|
||||
fireEvent.click(actionButtons[0])
|
||||
|
||||
const modalState = setShowExternalDataToolModal.mock.calls[0][0]
|
||||
expect(modalState.onValidateBeforeSaveCallback?.({
|
||||
label: 'Duplicated API Var',
|
||||
type: 'api',
|
||||
variable: 'existing',
|
||||
})).toBe(false)
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(onPromptVariablesChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Editing flows for variables through the modal.
|
||||
|
||||
@@ -1,84 +1,19 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getNewVar, hasDuplicateStr } from '@/utils/var'
|
||||
import Panel from '../base/feature-panel'
|
||||
import EditModal from './config-modal'
|
||||
import SelectVarType from './select-var-type'
|
||||
import { useConfigVarState } from './use-config-var-state'
|
||||
import VarItem from './var-item'
|
||||
|
||||
export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL'
|
||||
|
||||
type ExternalDataToolParams = {
|
||||
key: string
|
||||
type: string
|
||||
index: number
|
||||
name: string
|
||||
config?: PromptVariable['config']
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
}
|
||||
|
||||
const BASIC_INPUT_TYPES = new Set(['string', 'paragraph', 'select', 'number', 'checkbox'])
|
||||
|
||||
const toInputVar = (item: PromptVariable): InputVar => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
type: (item.type === 'string' ? InputVarType.textInput : item.type) as InputVarType,
|
||||
required: item.required ?? false,
|
||||
})
|
||||
|
||||
const buildPromptVariableFromInput = (payload: InputVar): PromptVariable => {
|
||||
const { variable, label, type, ...rest } = payload
|
||||
const nextType = type === InputVarType.textInput ? 'string' : type
|
||||
const nextItem: PromptVariable = {
|
||||
...rest,
|
||||
type: nextType,
|
||||
key: variable,
|
||||
name: label as string,
|
||||
}
|
||||
|
||||
if (payload.type !== InputVarType.select)
|
||||
delete nextItem.options
|
||||
|
||||
return nextItem
|
||||
}
|
||||
|
||||
const getDuplicateError = (list: PromptVariable[]) => {
|
||||
if (hasDuplicateStr(list.map(item => item.key))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.varName',
|
||||
}
|
||||
}
|
||||
if (hasDuplicateStr(list.map(item => item.name as string))) {
|
||||
return {
|
||||
errorMsgKey: 'varKeyError.keyAlreadyExists',
|
||||
typeName: 'variableConfig.labelName',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
export { ADD_EXTERNAL_DATA_TOOL } from './helpers'
|
||||
|
||||
export type IConfigVarProps = {
|
||||
promptVariables: PromptVariable[]
|
||||
@@ -89,159 +24,27 @@ export type IConfigVarProps = {
|
||||
const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVariablesChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
mode,
|
||||
dataSets,
|
||||
} = useContext(ConfigContext)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const hasVar = promptVariables.length > 0
|
||||
const [currIndex, setCurrIndex] = useState<number>(-1)
|
||||
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
|
||||
const currItemToEdit = useMemo(() => {
|
||||
if (!currItem)
|
||||
return null
|
||||
return toInputVar(currItem)
|
||||
}, [currItem])
|
||||
const updatePromptVariableItem = useCallback((payload: InputVar) => {
|
||||
const newPromptVariables = produce(promptVariables, (draft) => {
|
||||
draft[currIndex] = buildPromptVariableFromInput(payload)
|
||||
})
|
||||
const duplicateError = getDuplicateError(newPromptVariables)
|
||||
if (duplicateError) {
|
||||
toast.error(t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug', key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }) }) as string)
|
||||
return false
|
||||
}
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
return true
|
||||
}, [currIndex, onPromptVariablesChange, promptVariables, t])
|
||||
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
|
||||
const handleOpenExternalDataToolModal = useCallback((
|
||||
{ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams,
|
||||
oldPromptVariables: PromptVariable[],
|
||||
) => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {
|
||||
type,
|
||||
variable: key,
|
||||
label: name,
|
||||
config,
|
||||
icon,
|
||||
icon_background,
|
||||
},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
const newPromptVariables = oldPromptVariables.map((item, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
key: newExternalDataTool.variable as string,
|
||||
name: newExternalDataTool.label as string,
|
||||
enabled: newExternalDataTool.enabled,
|
||||
type: newExternalDataTool.type as string,
|
||||
config: newExternalDataTool.config,
|
||||
required: item.required,
|
||||
icon: newExternalDataTool.icon,
|
||||
icon_background: newExternalDataTool.icon_background,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
if (!key)
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable && i !== index) {
|
||||
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t])
|
||||
|
||||
const handleAddVar = useCallback((type: string) => {
|
||||
const newVar = getNewVar('', type)
|
||||
const newPromptVariables = [...promptVariables, newVar]
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
|
||||
if (type === 'api') {
|
||||
handleOpenExternalDataToolModal({
|
||||
type,
|
||||
key: newVar.key,
|
||||
name: newVar.name,
|
||||
index: promptVariables.length,
|
||||
}, newPromptVariables)
|
||||
}
|
||||
}, [handleOpenExternalDataToolModal, onPromptVariablesChange, promptVariables])
|
||||
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === ADD_EXTERNAL_DATA_TOOL) {
|
||||
const payload = v.payload
|
||||
onPromptVariablesChange?.([
|
||||
...promptVariables,
|
||||
{
|
||||
key: payload.variable as string,
|
||||
name: payload.label as string,
|
||||
enabled: payload.enabled,
|
||||
type: payload.type as string,
|
||||
config: payload.config,
|
||||
required: true,
|
||||
icon: payload.icon,
|
||||
icon_background: payload.icon_background,
|
||||
},
|
||||
])
|
||||
}
|
||||
canDrag,
|
||||
currItemToEdit,
|
||||
handleAddVar,
|
||||
handleConfig,
|
||||
handleDeleteContextVarConfirm,
|
||||
handleEditConfirm,
|
||||
handleRemoveVar,
|
||||
handleSort,
|
||||
hasVar,
|
||||
hideDeleteContextVarModal,
|
||||
hideEditModal,
|
||||
isShowDeleteContextVarModal,
|
||||
isShowEditModal,
|
||||
promptVariablesWithIds,
|
||||
removeIndex,
|
||||
} = useConfigVarState({
|
||||
promptVariables,
|
||||
readonly,
|
||||
onPromptVariablesChange,
|
||||
})
|
||||
|
||||
const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false)
|
||||
const [removeIndex, setRemoveIndex] = useState<number | null>(null)
|
||||
const didRemoveVar = useCallback((index: number) => {
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
|
||||
}, [onPromptVariablesChange, promptVariables])
|
||||
|
||||
const handleRemoveVar = useCallback((index: number) => {
|
||||
const removeVar = promptVariables[index]
|
||||
|
||||
if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) {
|
||||
showDeleteContextVarModal()
|
||||
setRemoveIndex(index)
|
||||
return
|
||||
}
|
||||
didRemoveVar(index)
|
||||
}, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal])
|
||||
|
||||
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
|
||||
|
||||
const handleConfig = useCallback(({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
|
||||
// setCurrKey(key)
|
||||
setCurrIndex(index)
|
||||
if (!BASIC_INPUT_TYPES.has(type)) {
|
||||
handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables)
|
||||
return
|
||||
}
|
||||
|
||||
showEditModal()
|
||||
}, [handleOpenExternalDataToolModal, promptVariables, showEditModal])
|
||||
|
||||
const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => {
|
||||
return {
|
||||
id: item.key,
|
||||
variable: { ...item },
|
||||
}
|
||||
}), [promptVariables])
|
||||
|
||||
const canDrag = !readonly && promptVariables.length > 1
|
||||
|
||||
return (
|
||||
<Panel
|
||||
className="mt-2"
|
||||
@@ -272,7 +75,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
<ReactSortable
|
||||
className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')}
|
||||
list={promptVariablesWithIds}
|
||||
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
|
||||
setList={handleSort}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
@@ -303,12 +106,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
payload={currItemToEdit!}
|
||||
isShow={isShowEditModal}
|
||||
onClose={hideEditModal}
|
||||
onConfirm={(item) => {
|
||||
const isValid = updatePromptVariableItem(item)
|
||||
if (!isValid)
|
||||
return
|
||||
hideEditModal()
|
||||
}}
|
||||
onConfirm={handleEditConfirm}
|
||||
varKeys={promptVariables.map(v => v.key)}
|
||||
/>
|
||||
)}
|
||||
@@ -318,10 +116,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
isShow={isShowDeleteContextVarModal}
|
||||
title={t('feature.dataSet.queryVariable.deleteContextVarTitle', { ns: 'appDebug', varName: promptVariables[removeIndex as number]?.name })}
|
||||
content={t('feature.dataSet.queryVariable.deleteContextVarTip', { ns: 'appDebug' })}
|
||||
onConfirm={() => {
|
||||
didRemoveVar(removeIndex as number)
|
||||
hideDeleteContextVarModal()
|
||||
}}
|
||||
onConfirm={handleDeleteContextVarConfirm}
|
||||
onCancel={hideDeleteContextVarModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { ExternalDataToolParams } from './helpers'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getNewVar } from '@/utils/var'
|
||||
import {
|
||||
ADD_EXTERNAL_DATA_TOOL,
|
||||
BASIC_INPUT_TYPES,
|
||||
buildPromptVariableFromExternalDataTool,
|
||||
buildPromptVariableFromInput,
|
||||
createPromptVariablesWithIds,
|
||||
|
||||
getDuplicateError,
|
||||
toInputVar,
|
||||
} from './helpers'
|
||||
|
||||
type ExternalDataToolEvent = {
|
||||
payload: ExternalDataTool
|
||||
type: string
|
||||
}
|
||||
|
||||
type UseConfigVarStateParams = {
|
||||
promptVariables: PromptVariable[]
|
||||
readonly?: boolean
|
||||
onPromptVariablesChange?: (promptVariables: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
export const useConfigVarState = ({
|
||||
promptVariables,
|
||||
readonly,
|
||||
onPromptVariablesChange,
|
||||
}: UseConfigVarStateParams) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
mode,
|
||||
dataSets,
|
||||
} = useContext(ConfigContext)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
|
||||
const hasVar = promptVariables.length > 0
|
||||
const [currIndex, setCurrIndex] = useState<number>(-1)
|
||||
const [removeIndex, setRemoveIndex] = useState<number | null>(null)
|
||||
const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false)
|
||||
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
|
||||
|
||||
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
|
||||
const currItemToEdit = useMemo(() => {
|
||||
if (!currItem)
|
||||
return null
|
||||
|
||||
return toInputVar(currItem)
|
||||
}, [currItem])
|
||||
|
||||
const openExternalDataToolModal = useCallback((
|
||||
{ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams,
|
||||
oldPromptVariables: PromptVariable[],
|
||||
) => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {
|
||||
type,
|
||||
variable: key,
|
||||
label: name,
|
||||
config,
|
||||
icon,
|
||||
icon_background,
|
||||
},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
|
||||
const newPromptVariables = oldPromptVariables.map((item, itemIndex) => {
|
||||
if (itemIndex === index)
|
||||
return buildPromptVariableFromExternalDataTool(newExternalDataTool, item.required ?? false)
|
||||
|
||||
return item
|
||||
})
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
if (!key)
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, itemIndex) => itemIndex !== index))
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable && i !== index) {
|
||||
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}, [onPromptVariablesChange, promptVariables, setShowExternalDataToolModal, t])
|
||||
|
||||
const updatePromptVariableItem = useCallback((payload: InputVar) => {
|
||||
const newPromptVariables = produce(promptVariables, (draft) => {
|
||||
draft[currIndex] = buildPromptVariableFromInput(payload)
|
||||
})
|
||||
const duplicateError = getDuplicateError(newPromptVariables)
|
||||
if (duplicateError) {
|
||||
toast.error(t(duplicateError.errorMsgKey as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, {
|
||||
ns: 'appDebug',
|
||||
key: t(duplicateError.typeName as I18nKeysByPrefix<'appDebug', 'duplicateError.'>, { ns: 'appDebug' }),
|
||||
}) as string)
|
||||
return false
|
||||
}
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
return true
|
||||
}, [currIndex, onPromptVariablesChange, promptVariables, t])
|
||||
|
||||
const handleAddVar = useCallback((type: string) => {
|
||||
const newVar = getNewVar('', type)
|
||||
const newPromptVariables = [...promptVariables, newVar]
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
|
||||
if (type === 'api') {
|
||||
openExternalDataToolModal({
|
||||
type,
|
||||
key: newVar.key,
|
||||
name: newVar.name,
|
||||
index: promptVariables.length,
|
||||
}, newPromptVariables)
|
||||
}
|
||||
}, [onPromptVariablesChange, openExternalDataToolModal, promptVariables])
|
||||
|
||||
eventEmitter?.useSubscription((event) => {
|
||||
if (typeof event === 'string' || event.type !== ADD_EXTERNAL_DATA_TOOL || !event.payload)
|
||||
return
|
||||
|
||||
onPromptVariablesChange?.([
|
||||
...promptVariables,
|
||||
buildPromptVariableFromExternalDataTool(event.payload as ExternalDataToolEvent['payload'], true),
|
||||
])
|
||||
})
|
||||
|
||||
const didRemoveVar = useCallback((index: number) => {
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, itemIndex) => itemIndex !== index))
|
||||
}, [onPromptVariablesChange, promptVariables])
|
||||
|
||||
const handleRemoveVar = useCallback((index: number) => {
|
||||
const removeVar = promptVariables[index]
|
||||
|
||||
if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) {
|
||||
showDeleteContextVarModal()
|
||||
setRemoveIndex(index)
|
||||
return
|
||||
}
|
||||
|
||||
didRemoveVar(index)
|
||||
}, [dataSets.length, didRemoveVar, mode, promptVariables, showDeleteContextVarModal])
|
||||
|
||||
const handleConfig = useCallback((params: ExternalDataToolParams) => {
|
||||
setCurrIndex(params.index)
|
||||
if (!BASIC_INPUT_TYPES.has(params.type)) {
|
||||
openExternalDataToolModal(params, promptVariables)
|
||||
return
|
||||
}
|
||||
|
||||
showEditModal()
|
||||
}, [openExternalDataToolModal, promptVariables, showEditModal])
|
||||
|
||||
const handleSort = useCallback((list: ReturnType<typeof createPromptVariablesWithIds>) => {
|
||||
onPromptVariablesChange?.(list.map(item => item.variable))
|
||||
}, [onPromptVariablesChange])
|
||||
|
||||
const handleEditConfirm = useCallback((item: InputVar) => {
|
||||
const isValid = updatePromptVariableItem(item)
|
||||
if (!isValid)
|
||||
return false
|
||||
|
||||
hideEditModal()
|
||||
return true
|
||||
}, [hideEditModal, updatePromptVariableItem])
|
||||
|
||||
const handleDeleteContextVarConfirm = useCallback(() => {
|
||||
didRemoveVar(removeIndex as number)
|
||||
hideDeleteContextVarModal()
|
||||
}, [didRemoveVar, hideDeleteContextVarModal, removeIndex])
|
||||
|
||||
const promptVariablesWithIds = useMemo(() => createPromptVariablesWithIds(promptVariables), [promptVariables])
|
||||
const canDrag = !readonly && promptVariables.length > 1
|
||||
|
||||
return {
|
||||
canDrag,
|
||||
currItemToEdit,
|
||||
handleAddVar,
|
||||
handleConfig,
|
||||
handleDeleteContextVarConfirm,
|
||||
handleRemoveVar,
|
||||
handleSort,
|
||||
handleEditConfirm,
|
||||
hasVar,
|
||||
hideDeleteContextVarModal,
|
||||
hideEditModal,
|
||||
isShowDeleteContextVarModal,
|
||||
isShowEditModal,
|
||||
promptVariablesWithIds,
|
||||
removeIndex,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Node, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { render } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import InstructionEditorInWorkflow from '../instruction-editor-in-workflow'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockPromptEditor = vi.fn()
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
const mockGetState = vi.fn()
|
||||
const mockUseWorkflowVariableType = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptEditor(props)
|
||||
return <div data-testid="prompt-editor">{String(props.value ?? '')}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowVariableType: () => mockUseWorkflowVariableType(),
|
||||
}))
|
||||
|
||||
const availableNodes: Node[] = [{
|
||||
data: {
|
||||
title: 'Node A',
|
||||
type: 'llm',
|
||||
},
|
||||
height: 80,
|
||||
id: 'node-a',
|
||||
position: { x: 0, y: 0 },
|
||||
width: 160,
|
||||
}] as unknown as Node[]
|
||||
|
||||
describe('InstructionEditorInWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetState.mockReturnValue({
|
||||
nodesWithInspectVars: [{ nodeId: 'node-a' }, { nodeId: 'node-b' }],
|
||||
})
|
||||
mockUseWorkflowVariableType.mockReturnValue(() => 'string')
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableNodes,
|
||||
availableVars: [{ value_selector: ['node-a', 'text'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should wire workflow variables into the shared instruction editor', () => {
|
||||
render(
|
||||
<InstructionEditorInWorkflow
|
||||
editorKey="workflow-editor"
|
||||
generatorType={GeneratorType.prompt}
|
||||
isShowCurrentBlock
|
||||
nodeId="node-a"
|
||||
onChange={vi.fn()}
|
||||
value="Workflow prompt"
|
||||
/>,
|
||||
)
|
||||
|
||||
const filterVar = mockUseAvailableVarList.mock.calls[0][1].filterVar as (payload: Var, selector: ValueSelector) => boolean
|
||||
expect(filterVar({ type: VarType.string } as Var, ['node-a'])).toBe(true)
|
||||
expect(filterVar({ type: VarType.file } as Var, ['node-a'])).toBe(false)
|
||||
expect(filterVar({ type: VarType.string } as Var, ['node-c'])).toBe(false)
|
||||
|
||||
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
currentBlock: {
|
||||
generatorType: GeneratorType.prompt,
|
||||
show: true,
|
||||
},
|
||||
lastRunBlock: {
|
||||
show: true,
|
||||
},
|
||||
value: 'Workflow prompt',
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
show: true,
|
||||
variables: [{ value_selector: ['node-a', 'text'] }],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import InstructionEditor from '../instruction-editor'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockPromptEditor = vi.fn()
|
||||
const mockEmit = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPromptEditor(props)
|
||||
return <div data-testid="prompt-editor">{String(props.value ?? '')}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const availableVars: NodeOutPutVar[] = [{ value_selector: ['sys', 'query'] }] as unknown as NodeOutPutVar[]
|
||||
const availableNodes: Node[] = [{
|
||||
data: {
|
||||
title: 'Start Node',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
height: 100,
|
||||
id: 'start-node',
|
||||
position: { x: 10, y: 20 },
|
||||
width: 120,
|
||||
}] as unknown as Node[]
|
||||
|
||||
describe('InstructionEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render prompt placeholder blocks and insert-context trigger', () => {
|
||||
render(
|
||||
<InstructionEditor
|
||||
availableNodes={availableNodes}
|
||||
availableVars={availableVars}
|
||||
editorKey="editor-1"
|
||||
generatorType={GeneratorType.prompt}
|
||||
isShowCurrentBlock={false}
|
||||
isShowLastRunBlock={false}
|
||||
onChange={vi.fn()}
|
||||
value="Prompt value"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
currentBlock: {
|
||||
generatorType: GeneratorType.prompt,
|
||||
show: false,
|
||||
},
|
||||
errorMessageBlock: {
|
||||
show: false,
|
||||
},
|
||||
lastRunBlock: {
|
||||
show: false,
|
||||
},
|
||||
value: 'Prompt value',
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
show: true,
|
||||
variables: availableVars,
|
||||
workflowNodesMap: expect.objectContaining({
|
||||
'start-node': expect.objectContaining({
|
||||
title: 'Start Node',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
'sys': expect.objectContaining({
|
||||
title: 'workflow.blocks.start',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.generate.insertContext'))
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
instanceId: 'editor-1',
|
||||
type: PROMPT_EDITOR_INSERT_QUICKLY,
|
||||
})
|
||||
})
|
||||
|
||||
it('should enable code-specific blocks for code generators', () => {
|
||||
render(
|
||||
<InstructionEditor
|
||||
availableNodes={availableNodes}
|
||||
availableVars={availableVars}
|
||||
editorKey="editor-2"
|
||||
generatorType={GeneratorType.code}
|
||||
isShowCurrentBlock
|
||||
isShowLastRunBlock
|
||||
onChange={vi.fn()}
|
||||
value="Code value"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
currentBlock: {
|
||||
generatorType: GeneratorType.code,
|
||||
show: true,
|
||||
},
|
||||
errorMessageBlock: {
|
||||
show: true,
|
||||
},
|
||||
lastRunBlock: {
|
||||
show: true,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PromptToast from '../prompt-toast'
|
||||
|
||||
describe('PromptToast', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the optimization note and markdown message', () => {
|
||||
render(<PromptToast message="Hello **world**" />)
|
||||
|
||||
expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle folded state from the arrow trigger', () => {
|
||||
const { container } = render(<PromptToast message="Foldable message" />)
|
||||
const toggle = container.querySelector('.size-4.cursor-pointer')
|
||||
expect(toggle).not.toBeNull()
|
||||
|
||||
fireEvent.click(toggle!)
|
||||
expect(screen.queryByTestId('markdown-body')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(toggle!)
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ResPlaceholder from '../res-placeholder'
|
||||
|
||||
describe('ResPlaceholder', () => {
|
||||
it('should render the empty-state copy', () => {
|
||||
render(<ResPlaceholder />)
|
||||
|
||||
expect(screen.getByText('appDebug.generate.newNoDataLine1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import Result from '../result'
|
||||
import { GeneratorType } from '../types'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockPromptEditor = vi.fn()
|
||||
const mockCodeEditor = vi.fn()
|
||||
const mockUseAvailableVarList = vi.fn()
|
||||
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
default: (props: {
|
||||
value: string
|
||||
workflowVariableBlock: Record<string, unknown>
|
||||
}) => {
|
||||
mockPromptEditor(props)
|
||||
return <div data-testid="prompt-editor">{props.value}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor', () => ({
|
||||
default: (props: { value?: string }) => {
|
||||
mockCodeEditor(props)
|
||||
return <div data-testid="code-editor">{props.value}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-available-var-list', () => ({
|
||||
default: (...args: unknown[]) => mockUseAvailableVarList(...args),
|
||||
}))
|
||||
|
||||
const createCurrent = (overrides: Partial<GenRes> = {}): GenRes => ({
|
||||
message: 'Optimization note',
|
||||
modified: 'Generated result',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Result', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(toast, 'success').mockImplementation(vi.fn())
|
||||
mockUseAvailableVarList.mockReturnValue({
|
||||
availableNodes: [{
|
||||
data: {
|
||||
title: 'Start Node',
|
||||
type: BlockEnum.Start,
|
||||
},
|
||||
height: 100,
|
||||
id: 'start-node',
|
||||
position: { x: 10, y: 20 },
|
||||
width: 120,
|
||||
}],
|
||||
availableVars: [{ value_selector: ['sys', 'query'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should render prompt results in basic mode and support copy/apply actions', () => {
|
||||
const onApply = vi.fn()
|
||||
render(
|
||||
<Result
|
||||
current={createCurrent()}
|
||||
currentVersionIndex={0}
|
||||
generatorType={GeneratorType.prompt}
|
||||
isBasicMode
|
||||
onApply={onApply}
|
||||
setCurrentVersionIndex={vi.fn()}
|
||||
versions={[createCurrent()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'appDebug.generate.apply' }))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Generated result')
|
||||
expect(toast.success).toHaveBeenCalledWith('common.actionMsg.copySuccessfully')
|
||||
expect(onApply).toHaveBeenCalledTimes(1)
|
||||
expect(mockPromptEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: 'Generated result',
|
||||
workflowVariableBlock: {
|
||||
show: false,
|
||||
},
|
||||
}))
|
||||
expect(screen.getByText('appDebug.generate.optimizationNote')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('markdown-body')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow prompt results with workflow variable metadata', () => {
|
||||
render(
|
||||
<Result
|
||||
current={createCurrent({ message: undefined, modified: 'v2' })}
|
||||
currentVersionIndex={1}
|
||||
generatorType={GeneratorType.prompt}
|
||||
nodeId="node-1"
|
||||
onApply={vi.fn()}
|
||||
setCurrentVersionIndex={vi.fn()}
|
||||
versions={[createCurrent({ modified: 'v1' }), createCurrent({ message: undefined, modified: 'v2' })]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const promptEditorProps = mockPromptEditor.mock.lastCall?.[0]
|
||||
expect(promptEditorProps).toEqual(expect.objectContaining({
|
||||
value: 'v2',
|
||||
workflowVariableBlock: expect.objectContaining({
|
||||
show: true,
|
||||
variables: [{ value_selector: ['sys', 'query'] }],
|
||||
workflowNodesMap: expect.objectContaining({
|
||||
'start-node': expect.objectContaining({
|
||||
title: 'Start Node',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
'sys': expect.objectContaining({
|
||||
title: 'workflow.blocks.start',
|
||||
type: BlockEnum.Start,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render code results through the code editor branch', () => {
|
||||
render(
|
||||
<Result
|
||||
current={createCurrent({ modified: '{"name":"demo"}' })}
|
||||
currentVersionIndex={0}
|
||||
generatorType={GeneratorType.code}
|
||||
onApply={vi.fn()}
|
||||
setCurrentVersionIndex={vi.fn()}
|
||||
versions={[createCurrent({ modified: '{"name":"demo"}' })]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('code-editor')).toHaveTextContent('{"name":"demo"}')
|
||||
expect(mockCodeEditor).toHaveBeenCalledWith(expect.objectContaining({
|
||||
value: '{"name":"demo"}',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import useGenData from '../use-gen-data'
|
||||
|
||||
describe('useGenData', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('should initialize empty version state for a new storage key', () => {
|
||||
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
|
||||
|
||||
expect(result.current.versions).toEqual([])
|
||||
expect(result.current.currentVersionIndex).toBe(0)
|
||||
expect(result.current.current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should append versions and move the current index to the latest version', () => {
|
||||
const { result } = renderHook(() => useGenData({ storageKey: 'prompt' }))
|
||||
|
||||
act(() => {
|
||||
result.current.addVersion({ modified: 'first' })
|
||||
})
|
||||
expect(result.current.versions).toEqual([{ modified: 'first' }])
|
||||
expect(result.current.currentVersionIndex).toBe(0)
|
||||
expect(result.current.current).toEqual({ modified: 'first' })
|
||||
|
||||
act(() => {
|
||||
result.current.addVersion({ message: 'hint', modified: 'second' })
|
||||
})
|
||||
expect(result.current.versions).toEqual([
|
||||
{ modified: 'first' },
|
||||
{ message: 'hint', modified: 'second' },
|
||||
])
|
||||
expect(result.current.currentVersionIndex).toBe(1)
|
||||
expect(result.current.current).toEqual({ message: 'hint', modified: 'second' })
|
||||
})
|
||||
|
||||
it('should persist and restore versions by storage key', () => {
|
||||
const { result, unmount } = renderHook(() => useGenData({ storageKey: 'prompt' }))
|
||||
|
||||
act(() => {
|
||||
result.current.addVersion({ modified: 'first' })
|
||||
})
|
||||
act(() => {
|
||||
result.current.addVersion({ modified: 'second' })
|
||||
})
|
||||
act(() => {
|
||||
result.current.setCurrentVersionIndex(0)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
const { result: nextResult } = renderHook(() => useGenData({ storageKey: 'prompt' }))
|
||||
expect(nextResult.current.versions).toEqual([
|
||||
{ modified: 'first' },
|
||||
{ modified: 'second' },
|
||||
])
|
||||
expect(nextResult.current.currentVersionIndex).toBe(0)
|
||||
expect(nextResult.current.current).toEqual({ modified: 'first' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import VersionSelector from '../version-selector'
|
||||
|
||||
describe('VersionSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const clickTrigger = () => {
|
||||
fireEvent.click(screen.getByText('appDebug.generate.version 2 · appDebug.generate.latest'))
|
||||
}
|
||||
|
||||
it('should render the current version label and keep single-version selectors closed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<VersionSelector onChange={onChange} value={0} versionLen={1} />)
|
||||
|
||||
fireEvent.click(screen.getByText('appDebug.generate.version 1 · appDebug.generate.latest'))
|
||||
|
||||
expect(screen.queryByText('appDebug.generate.versions')).not.toBeInTheDocument()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open the selector and emit the chosen version', async () => {
|
||||
const onChange = vi.fn()
|
||||
render(<VersionSelector onChange={onChange} value={1} versionLen={2} />)
|
||||
|
||||
clickTrigger()
|
||||
fireEvent.click(await screen.findByTitle('appDebug.generate.version 1'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('appDebug.generate.versions')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,534 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Node } from 'reactflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import ReasoningConfigForm from '../reasoning-config-form'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/select', () => ({
|
||||
Select: ({
|
||||
children,
|
||||
onValueChange,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onValueChange: (value: string) => void
|
||||
value?: string
|
||||
}) => (
|
||||
<div data-testid="select-root" data-value={value}>
|
||||
{children}
|
||||
<button onClick={() => onValueChange('selected-option')}>Choose Select Option</button>
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
SelectValue: ({ placeholder }: { placeholder?: string }) => <span>{placeholder ?? 'Select'}</span>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectItem: ({ children, value }: { children: ReactNode, value: string }) => <div data-testid={`select-item-${value}`}>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={className?.includes('cursor-pointer') ? 'schema-trigger' : 'tooltip-trigger'}
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
|
||||
default: ({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (value: { app_id: string, inputs: Record<string, unknown>, files?: unknown[] }) => void
|
||||
}) => <button onClick={() => onSelect({ app_id: 'app-1', inputs: { query: 'hello' } })}>Select App</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
|
||||
default: ({
|
||||
setModel,
|
||||
}: {
|
||||
setModel: (value: Record<string, unknown>) => void
|
||||
}) => <button onClick={() => setModel({ model: 'gpt-4o-mini' })}>Select Model</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (value: unknown) => void
|
||||
}) => <button onClick={() => onChange({ from: 'editor' })}>Update JSON</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/form-input-boolean', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) => <button onClick={() => onChange(!value)}>Toggle Boolean</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/form-input-type-switch', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: VarKindType) => void
|
||||
}) => (
|
||||
<div data-testid="type-switch">
|
||||
<span>{value}</span>
|
||||
<button onClick={() => onChange(VarKindType.variable)}>Switch To Variable</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
filterVar,
|
||||
valueTypePlaceHolder,
|
||||
}: {
|
||||
onChange: (value: string[]) => void
|
||||
filterVar?: (value: { type: string }) => boolean
|
||||
valueTypePlaceHolder?: string
|
||||
}) => {
|
||||
const matchesFilter = filterVar?.({ type: valueTypePlaceHolder ?? 'unknown' }) ?? false
|
||||
return (
|
||||
<div data-testid={`var-picker-${String(valueTypePlaceHolder)}`}>
|
||||
<span>{`matches-filter:${String(matchesFilter)}`}</span>
|
||||
<button onClick={() => onChange(['node-1', 'var-1'])}>Select Variable</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/tool/components/mixed-variable-text-input', () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) => (
|
||||
<div data-testid="mixed-variable-text-input">
|
||||
<span>{value}</span>
|
||||
<button onClick={() => onChange('mixed-updated')}>Update Mixed Text</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../schema-modal', () => ({
|
||||
default: ({
|
||||
isShow,
|
||||
rootName,
|
||||
onClose,
|
||||
}: {
|
||||
isShow: boolean
|
||||
rootName: string
|
||||
onClose: () => void
|
||||
}) => (
|
||||
isShow
|
||||
? (
|
||||
<div data-testid="schema-modal">
|
||||
<span>{rootName}</span>
|
||||
<button onClick={onClose}>Close Schema</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
const availableNodes: Node[] = []
|
||||
|
||||
const createSchema = (overrides: Record<string, unknown>) => ({
|
||||
default: '',
|
||||
variable: 'field',
|
||||
label: { en_US: 'Field' },
|
||||
required: false,
|
||||
tooltip: { en_US: 'Helpful tooltip' },
|
||||
type: FormTypeEnum.textInput,
|
||||
scope: 'all',
|
||||
url: undefined,
|
||||
input_schema: undefined,
|
||||
placeholder: { en_US: 'Enter value' },
|
||||
options: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createValue = (value: Record<string, unknown>) => value
|
||||
|
||||
describe('ReasoningConfigForm', () => {
|
||||
it('should render mixed text input and support auto mode toggling when variable reference is allowed', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
prompt: { auto: 0, value: { type: VarKindType.mixed, value: 'hello' } },
|
||||
})}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'prompt',
|
||||
label: { en_US: 'Prompt' },
|
||||
type: FormTypeEnum.textInput,
|
||||
}),
|
||||
]}
|
||||
nodeId="node-1"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('mixed-variable-text-input')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update Mixed Text' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
prompt: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.mixed,
|
||||
value: 'mixed-updated',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByText('plugin.detailPanel.toolSelector.auto'))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
prompt: {
|
||||
auto: 1,
|
||||
value: null,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should use plain input when variable reference is disabled', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
plain_text: { auto: 0, value: { type: VarKindType.mixed, value: 'before' } },
|
||||
})}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'plain_text',
|
||||
label: { en_US: 'Plain Text' },
|
||||
type: FormTypeEnum.secretInput,
|
||||
placeholder: { en_US: 'Enter prompt' },
|
||||
}),
|
||||
]}
|
||||
disableVariableReference
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter prompt'), { target: { value: 'after' } })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
plain_text: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.mixed,
|
||||
value: 'after',
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should update typed fields, selectors, and variable references across supported schema types', () => {
|
||||
const onChange = vi.fn()
|
||||
const enabledValue = { auto: 0, value: { type: VarKindType.constant, value: false } }
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
count: { auto: 0, value: { type: VarKindType.constant, value: '2' } },
|
||||
enabled: enabledValue,
|
||||
mode: { auto: 0, value: { type: VarKindType.constant, value: 'draft' } },
|
||||
assistant: { value: null, auto: 0 },
|
||||
model: { value: { provider: 'openai' }, auto: 0 },
|
||||
attachments: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
})}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'count',
|
||||
label: { en_US: 'Count' },
|
||||
type: FormTypeEnum.textNumber,
|
||||
placeholder: { en_US: 'Enter number' },
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'enabled',
|
||||
label: { en_US: 'Enabled' },
|
||||
type: FormTypeEnum.checkbox,
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'mode',
|
||||
label: { en_US: 'Mode' },
|
||||
type: FormTypeEnum.select,
|
||||
options: [
|
||||
{
|
||||
value: 'selected-option',
|
||||
label: { en_US: 'Selected Option' },
|
||||
show_on: [{ variable: 'enabled', value: enabledValue }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'assistant',
|
||||
label: { en_US: 'Assistant' },
|
||||
type: FormTypeEnum.appSelector,
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'model',
|
||||
label: { en_US: 'Model' },
|
||||
type: FormTypeEnum.modelSelector,
|
||||
}),
|
||||
createSchema({
|
||||
variable: 'attachments',
|
||||
label: { en_US: 'Attachments' },
|
||||
type: FormTypeEnum.files,
|
||||
}),
|
||||
]}
|
||||
nodeId="node-2"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter number'), { target: { value: '3' } })
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
count: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: '3',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch To Variable' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
count: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.variable,
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle Boolean' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
enabled: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose Select Option' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
mode: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: 'selected-option',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select App' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
assistant: expect.objectContaining({
|
||||
value: {
|
||||
app_id: 'app-1',
|
||||
inputs: { query: 'hello' },
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Model' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
value: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Variable' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
attachments: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.variable,
|
||||
value: ['node-1', 'var-1'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(screen.getAllByText('plugin.detailPanel.toolSelector.auto').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should toggle file auto mode through the switch control', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
file_input: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
})}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'file_input',
|
||||
label: { en_US: 'File Input' },
|
||||
type: FormTypeEnum.file,
|
||||
}),
|
||||
]}
|
||||
nodeId="node-5"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
file_input: {
|
||||
auto: 1,
|
||||
value: null,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should compute variable filters for number, string, boolean, object, array, and files selectors', () => {
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
string_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
number_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
boolean_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
object_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
array_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
files_var: { auto: 0, value: { type: VarKindType.variable, value: [] } },
|
||||
})}
|
||||
onChange={vi.fn()}
|
||||
schemas={[
|
||||
createSchema({ variable: 'string_var', label: { en_US: 'String Var' }, type: FormTypeEnum.textInput }),
|
||||
createSchema({ variable: 'number_var', label: { en_US: 'Number Var' }, type: FormTypeEnum.textNumber }),
|
||||
createSchema({ variable: 'boolean_var', label: { en_US: 'Boolean Var' }, type: FormTypeEnum.checkbox }),
|
||||
createSchema({ variable: 'object_var', label: { en_US: 'Object Var' }, type: FormTypeEnum.object }),
|
||||
createSchema({ variable: 'array_var', label: { en_US: 'Array Var' }, type: FormTypeEnum.array }),
|
||||
createSchema({ variable: 'files_var', label: { en_US: 'Files Var' }, type: FormTypeEnum.files }),
|
||||
]}
|
||||
nodeId="node-6"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId(`var-picker-${VarType.string}`)).toHaveTextContent('matches-filter:true')
|
||||
expect(screen.getByTestId(`var-picker-${VarType.number}`)).toHaveTextContent('matches-filter:true')
|
||||
expect(screen.getByTestId(`var-picker-${VarType.boolean}`)).toHaveTextContent('matches-filter:true')
|
||||
expect(screen.getByTestId(`var-picker-${VarType.object}`)).toHaveTextContent('matches-filter:true')
|
||||
expect(screen.getByTestId(`var-picker-${VarType.arrayObject}`)).toHaveTextContent('matches-filter:true')
|
||||
expect(screen.getByTestId(`var-picker-${VarType.arrayFile}`)).toHaveTextContent('matches-filter:true')
|
||||
})
|
||||
|
||||
it('should render json editor, schema modal, and help link for structured schemas', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
payload: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: { answer: '42' },
|
||||
},
|
||||
},
|
||||
})}
|
||||
onChange={onChange}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'payload',
|
||||
label: { en_US: 'Payload' },
|
||||
type: FormTypeEnum.object,
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
url: 'https://example.com/docs',
|
||||
placeholder: { en_US: 'Enter JSON' },
|
||||
}),
|
||||
]}
|
||||
nodeId="node-3"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update JSON' }))
|
||||
expect(onChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
payload: {
|
||||
auto: 0,
|
||||
value: {
|
||||
type: VarKindType.constant,
|
||||
value: { from: 'editor' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
fireEvent.click(screen.getByTestId('schema-trigger'))
|
||||
expect(screen.getByTestId('schema-modal')).toHaveTextContent('Payload')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close Schema' }))
|
||||
expect(screen.queryByTestId('schema-modal')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'tools.howToGet' })).toHaveAttribute('href', 'https://example.com/docs')
|
||||
})
|
||||
|
||||
it('should hide auto toggle for model selector schemas', () => {
|
||||
render(
|
||||
<ReasoningConfigForm
|
||||
value={createValue({
|
||||
model_only: { value: { provider: 'openai' }, auto: 0 },
|
||||
})}
|
||||
onChange={vi.fn()}
|
||||
schemas={[
|
||||
createSchema({
|
||||
variable: 'model_only',
|
||||
label: { en_US: 'Model Only' },
|
||||
type: FormTypeEnum.modelSelector,
|
||||
}),
|
||||
]}
|
||||
nodeId="node-4"
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('plugin.detailPanel.toolSelector.auto')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SchemaRoot } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SchemaModal from '../schema-modal'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor', () => ({
|
||||
default: ({
|
||||
rootName,
|
||||
readOnly,
|
||||
}: {
|
||||
rootName: string
|
||||
readOnly?: boolean
|
||||
}) => (
|
||||
<div data-testid="visual-editor">
|
||||
<span>{rootName}</span>
|
||||
<span>{readOnly ? 'readonly' : 'editable'}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context', () => ({
|
||||
MittProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
VisualEditorContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
} as unknown as SchemaRoot
|
||||
|
||||
describe('SchemaModal', () => {
|
||||
it('should not render dialog content when hidden', () => {
|
||||
render(
|
||||
<SchemaModal
|
||||
isShow={false}
|
||||
schema={schema}
|
||||
rootName="Hidden Schema"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('workflow.nodes.agent.parameterSchema')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('visual-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render schema title and visual editor when shown', () => {
|
||||
render(
|
||||
<SchemaModal
|
||||
isShow
|
||||
schema={schema}
|
||||
rootName="Tool Schema"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('workflow.nodes.agent.parameterSchema')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('visual-editor')).toHaveTextContent('Tool Schema')
|
||||
expect(screen.getByTestId('visual-editor')).toHaveTextContent('readonly')
|
||||
})
|
||||
|
||||
it('should call onClose when the close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<SchemaModal
|
||||
isShow
|
||||
schema={schema}
|
||||
rootName="Closable Schema"
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import ToolAuthorizationSection from '../tool-authorization-section'
|
||||
|
||||
const mockPluginAuthInAgent = vi.fn(({
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
}: {
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
}) => (
|
||||
<div data-testid="plugin-auth-in-agent">
|
||||
<span>{credentialId ?? 'no-credential'}</span>
|
||||
<button onClick={() => onAuthorizationItemClick?.('credential-1')}>Select Credential</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth', () => ({
|
||||
AuthCategory: { tool: 'tool' },
|
||||
PluginAuthInAgent: (props: {
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
}) => mockPluginAuthInAgent(props),
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
name: 'builtin-provider',
|
||||
type: CollectionType.builtIn,
|
||||
allow_delete: true,
|
||||
is_team_authorization: true,
|
||||
...overrides,
|
||||
} as ToolWithProvider)
|
||||
|
||||
describe('sections/tool-authorization-section', () => {
|
||||
it('should render nothing when provider is missing', () => {
|
||||
const { container } = render(<ToolAuthorizationSection />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render nothing for non built-in providers or providers without delete permission', () => {
|
||||
const { rerender, container } = render(
|
||||
<ToolAuthorizationSection currentProvider={createProvider({ type: CollectionType.custom })} />,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
|
||||
rerender(
|
||||
<ToolAuthorizationSection currentProvider={createProvider({ allow_delete: false })} />,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render divider and auth component for supported providers', () => {
|
||||
render(
|
||||
<ToolAuthorizationSection
|
||||
currentProvider={createProvider()}
|
||||
credentialId="credential-123"
|
||||
onAuthorizationItemClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('divider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plugin-auth-in-agent')).toHaveTextContent('credential-123')
|
||||
})
|
||||
|
||||
it('should hide divider when noDivider is true and forward authorization clicks', () => {
|
||||
const onAuthorizationItemClick = vi.fn()
|
||||
|
||||
render(
|
||||
<ToolAuthorizationSection
|
||||
currentProvider={createProvider()}
|
||||
noDivider
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('divider')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select Credential' }))
|
||||
|
||||
expect(onAuthorizationItemClick).toHaveBeenCalledWith('credential-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Node } from 'reactflow'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { NodeOutPutVar, ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ToolSettingsSection from '../tool-settings-section'
|
||||
|
||||
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
|
||||
getPlainValue: vi.fn((value: Record<string, unknown>) => value),
|
||||
getStructureValue: vi.fn(() => ({ structured: 'settings' })),
|
||||
toolParametersToFormSchemas: vi.fn((schemas: unknown[]) => schemas),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/tool/components/tool-form', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
schema,
|
||||
}: {
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
schema: unknown[]
|
||||
}) => (
|
||||
<div data-testid="tool-form">
|
||||
<span>{`schema-count:${schema.length}`}</span>
|
||||
<button onClick={() => onChange({ raw: 'settings' })}>Change Settings</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector/reasoning-config-form', () => ({
|
||||
default: ({
|
||||
onChange,
|
||||
nodeId,
|
||||
schemas,
|
||||
}: {
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
nodeId: string
|
||||
schemas: unknown[]
|
||||
}) => (
|
||||
<div data-testid="reasoning-config-form">
|
||||
<span>{`node:${nodeId}`}</span>
|
||||
<span>{`schema-count:${schemas.length}`}</span>
|
||||
<button onClick={() => onChange({ reasoning: { auto: 0, value: { temperature: 0.7 } } })}>Change Params</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
|
||||
name: 'provider',
|
||||
is_team_authorization: true,
|
||||
...overrides,
|
||||
} as ToolWithProvider)
|
||||
|
||||
const createTool = (parameters: Array<{ form: string, name: string }>): Tool => ({
|
||||
parameters,
|
||||
} as unknown as Tool)
|
||||
|
||||
const createValue = (overrides: Partial<ToolValue> = {}): ToolValue => ({
|
||||
provider_name: 'provider',
|
||||
tool_name: 'tool',
|
||||
settings: {
|
||||
setting1: {
|
||||
value: 'initial',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
reasoning: {
|
||||
auto: 0,
|
||||
value: {
|
||||
temperature: 0.2,
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as ToolValue)
|
||||
|
||||
const nodeOutputVars: NodeOutPutVar[] = []
|
||||
const availableNodes: Node[] = []
|
||||
|
||||
describe('sections/tool-settings-section', () => {
|
||||
it('should render nothing when provider is not team authorized', () => {
|
||||
const { container } = render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={createProvider({ is_team_authorization: false })}
|
||||
currentTool={createTool([{ form: 'form', name: 'setting1' }])}
|
||||
value={createValue()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render nothing when tool has no settings or params', () => {
|
||||
const { container } = render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={createProvider()}
|
||||
currentTool={createTool([])}
|
||||
value={createValue()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render user settings only and save structured settings', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={createProvider()}
|
||||
currentTool={createTool([{ form: 'form', name: 'setting1' }])}
|
||||
value={createValue()}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.toolSelector.settings')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-form')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('reasoning-config-form')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Settings' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
settings: { structured: 'settings' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render reasoning config only and save parameters', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={createProvider()}
|
||||
currentTool={createTool([{ form: 'llm', name: 'reasoning' }])}
|
||||
value={createValue()}
|
||||
nodeId="node-1"
|
||||
nodeOutputVars={nodeOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.toolSelector.params')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('node:node-1')
|
||||
expect(screen.getByTestId('tool-form')).toHaveTextContent('schema-count:0')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Change Params' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
parameters: {
|
||||
reasoning: {
|
||||
auto: 0,
|
||||
value: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render tab slider and switch from settings to params when both forms exist', () => {
|
||||
render(
|
||||
<ToolSettingsSection
|
||||
currentProvider={createProvider()}
|
||||
currentTool={createTool([
|
||||
{ form: 'form', name: 'setting1' },
|
||||
{ form: 'llm', name: 'reasoning' },
|
||||
])}
|
||||
value={createValue()}
|
||||
nodeId="node-2"
|
||||
nodeOutputVars={nodeOutputVars}
|
||||
availableNodes={availableNodes}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('tab-slider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tool-form')).toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.detailPanel.toolSelector.paramsTip1')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-slider-item-params'))
|
||||
|
||||
expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip1')).toBeInTheDocument()
|
||||
expect(screen.getByText('plugin.detailPanel.toolSelector.paramsTip2')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('reasoning-config-form')).toHaveTextContent('schema-count:1')
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,36 @@ import {
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
useTranslation: (defaultNs?: string) => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const ns = (options?.ns as string | undefined) ?? defaultNs
|
||||
const fullKey = ns ? `${ns}.${key}` : key
|
||||
const params = { ...options }
|
||||
delete params.ns
|
||||
const suffix = Object.keys(params).length > 0 ? `:${JSON.stringify(params)}` : ''
|
||||
return `${fullKey}${suffix}`
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey, components }: {
|
||||
i18nKey: string
|
||||
components?: Record<string, React.ReactElement>
|
||||
}) => {
|
||||
const timezoneComponent = components?.setTimezone
|
||||
if (timezoneComponent && React.isValidElement(timezoneComponent))
|
||||
return React.createElement(timezoneComponent.type, timezoneComponent.props, i18nKey)
|
||||
|
||||
return React.createElement('span', { 'data-i18n-key': i18nKey }, i18nKey)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app context
|
||||
const mockTimezone = 'America/New_York'
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@@ -93,16 +123,20 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
let latestFilteredMinutes: string[] = []
|
||||
|
||||
// Mock TimePicker component - simplified stateless mock
|
||||
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
|
||||
default: ({ value, onChange, onClear, renderTrigger }: {
|
||||
default: ({ value, onChange, onClear, renderTrigger, minuteFilter }: {
|
||||
value: { format: (f: string) => string }
|
||||
onChange: (v: unknown) => void
|
||||
onClear: () => void
|
||||
minuteFilter?: (minutes: string[]) => string[]
|
||||
title?: string
|
||||
renderTrigger: (params: { inputElem: React.ReactNode, onClick: () => void, isOpen: boolean }) => React.ReactNode
|
||||
}) => {
|
||||
const inputElem = <span data-testid="time-input">{value.format('HH:mm')}</span>
|
||||
latestFilteredMinutes = minuteFilter?.(['00', '07', '15', '30', '45']) ?? []
|
||||
|
||||
return (
|
||||
<div data-testid="time-picker">
|
||||
@@ -112,6 +146,7 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
|
||||
isOpen: false,
|
||||
})}
|
||||
<div data-testid="time-picker-dropdown">
|
||||
<div data-testid="filtered-minutes">{latestFilteredMinutes.join(',')}</div>
|
||||
<button
|
||||
data-testid="time-picker-set"
|
||||
onClick={() => {
|
||||
@@ -320,6 +355,7 @@ describe('auto-update-setting', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPortalOpen = false
|
||||
forcePortalContentVisible = false
|
||||
latestFilteredMinutes = []
|
||||
mockPluginsData.plugins = []
|
||||
})
|
||||
|
||||
@@ -1459,9 +1495,10 @@ describe('auto-update-setting', () => {
|
||||
|
||||
// Act
|
||||
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
||||
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
|
||||
|
||||
// Assert - timezone Trans component is rendered
|
||||
expect(screen.getByText('autoUpdate.changeTimezone')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'language' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1473,9 +1510,8 @@ describe('auto-update-setting', () => {
|
||||
// Act
|
||||
render(<AutoUpdateSetting {...defaultProps} payload={payload} />)
|
||||
|
||||
// The minuteFilter is passed to TimePicker internally
|
||||
// We verify the component renders correctly
|
||||
expect(screen.getByTestId('time-picker')).toBeInTheDocument()
|
||||
// Assert
|
||||
expect(screen.getByTestId('filtered-minutes')).toHaveTextContent('00,15,30,45')
|
||||
})
|
||||
|
||||
it('handleChange should preserve other config values', () => {
|
||||
|
||||
Reference in New Issue
Block a user