test: add unit tests for configuration components including instruction editor, prompt toast, and variable management

This commit is contained in:
CodingOnStar
2026-03-30 11:24:43 +08:00
parent d331915340
commit bfce8c5b2c
21 changed files with 2114 additions and 241 deletions

View File

@@ -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' }) },
])
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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()
})
})
})

View File

@@ -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')
})
})

View 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 },
}
})
}

View File

@@ -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.

View File

@@ -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}
/>
)}

View File

@@ -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,
}
}

View File

@@ -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'] }],
}),
}))
})
})

View File

@@ -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,
},
}))
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})

View File

@@ -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"}',
}))
})
})

View File

@@ -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' })
})
})

View File

@@ -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()
})
})
})

View File

@@ -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()
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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', () => {