mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
This commit is contained in:
@ -146,4 +146,30 @@ describe('AnnotationCtrlButton', () => {
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fallback author name to empty string when account name is missing', async () => {
|
||||
const onAdded = vi.fn()
|
||||
mockAddAnnotation.mockResolvedValueOnce({
|
||||
id: 'annotation-2',
|
||||
account: undefined,
|
||||
})
|
||||
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
messageId="msg-2"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={onAdded}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onAdded).toHaveBeenCalledWith('annotation-2', '')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -39,6 +39,19 @@ vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
vi.mock('../score-slider', () => ({
|
||||
default: ({ value, onChange }: { value: number, onChange: (value: number) => void }) => (
|
||||
<input
|
||||
role="slider"
|
||||
type="range"
|
||||
min={80}
|
||||
max={100}
|
||||
value={value}
|
||||
onChange={e => onChange(Number((e.target as HTMLInputElement).value))}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultAnnotationConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
@ -158,7 +171,7 @@ describe('ConfigParamModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.90')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should render configConfirmBtn when isInit is false', () => {
|
||||
@ -262,9 +275,9 @@ describe('ConfigParamModal', () => {
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
expect(slider).toHaveAttribute('min', '80')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should update embedding model when model selector is used', () => {
|
||||
@ -377,7 +390,7 @@ describe('ConfigParamModal', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
|
||||
expect(screen.getByRole('slider')).toHaveValue('90')
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
@ -412,4 +425,30 @@ describe('ConfigParamModal', () => {
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should save updated score after slider changes', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: '96' } })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ embedding_provider_name: 'openai' }),
|
||||
0.96,
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import type { Features } from '../../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import AnnotationReply from '../index'
|
||||
|
||||
const originalConsoleError = console.error
|
||||
const mockPush = vi.fn()
|
||||
let mockPathname = '/app/test-app-id/configuration'
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
usePathname: () => mockPathname,
|
||||
}))
|
||||
|
||||
let mockIsShowAnnotationConfigInit = false
|
||||
@ -100,6 +102,15 @@ const renderWithProvider = (
|
||||
describe('AnnotationReply', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
const message = args.map(arg => String(arg)).join(' ')
|
||||
if (message.includes('A props object containing a "key" prop is being spread into JSX')
|
||||
|| message.includes('React keys must be passed directly to JSX without using spread')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args as Parameters<typeof console.error>)
|
||||
})
|
||||
mockPathname = '/app/test-app-id/configuration'
|
||||
mockIsShowAnnotationConfigInit = false
|
||||
mockIsShowAnnotationFullModal = false
|
||||
capturedSetAnnotationConfig = null
|
||||
@ -235,18 +246,47 @@ describe('AnnotationReply', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/annotations')
|
||||
})
|
||||
|
||||
it('should show config param modal when isShowAnnotationConfigInit is true', () => {
|
||||
it('should fallback appId to empty string when pathname does not match', () => {
|
||||
mockPathname = '/apps/no-match'
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/feature\.annotation\.cacheManagement/))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/app//annotations')
|
||||
})
|
||||
|
||||
it('should show config param modal when isShowAnnotationConfigInit is true', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
await act(async () => {
|
||||
renderWithProvider()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide config modal when hide is clicked', () => {
|
||||
it('should hide config modal when hide is clicked', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
await act(async () => {
|
||||
renderWithProvider()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
@ -264,7 +304,10 @@ describe('AnnotationReply', () => {
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalled()
|
||||
})
|
||||
@ -298,7 +341,10 @@ describe('AnnotationReply', () => {
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// handleEnableAnnotation should be called with embedding model and score
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalledWith(
|
||||
@ -327,13 +373,15 @@ describe('AnnotationReply', () => {
|
||||
|
||||
// The captured setAnnotationConfig is the component's updateAnnotationReply callback
|
||||
expect(capturedSetAnnotationConfig).not.toBeNull()
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.8,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'new-model',
|
||||
},
|
||||
act(() => {
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.8,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'new-model',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
@ -353,12 +401,12 @@ describe('AnnotationReply', () => {
|
||||
|
||||
// Should not throw when onChange is not provided
|
||||
expect(capturedSetAnnotationConfig).not.toBeNull()
|
||||
expect(() => {
|
||||
expect(() => act(() => {
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.7,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
@ -403,9 +451,12 @@ describe('AnnotationReply', () => {
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isInit prop to ConfigParamModal', () => {
|
||||
it('should pass isInit prop to ConfigParamModal', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
await act(async () => {
|
||||
renderWithProvider()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/initSetup\.configConfirmBtn/)).not.toBeInTheDocument()
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { queryAnnotationJobStatus } from '@/service/annotation'
|
||||
import { sleep } from '@/utils'
|
||||
import useAnnotationConfig from '../use-annotation-config'
|
||||
|
||||
let mockIsAnnotationFull = false
|
||||
@ -238,4 +240,31 @@ describe('useAnnotationConfig', () => {
|
||||
expect(updatedConfig.enabled).toBe(true)
|
||||
expect(updatedConfig.score_threshold).toBeDefined()
|
||||
})
|
||||
|
||||
it('should poll job status until completed when enabling annotation', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const queryJobStatusMock = vi.mocked(queryAnnotationJobStatus)
|
||||
const sleepMock = vi.mocked(sleep)
|
||||
|
||||
queryJobStatusMock
|
||||
.mockResolvedValueOnce({ job_status: 'pending' } as unknown as Awaited<ReturnType<typeof queryAnnotationJobStatus>>)
|
||||
.mockResolvedValueOnce({ job_status: 'completed' } as unknown as Awaited<ReturnType<typeof queryAnnotationJobStatus>>)
|
||||
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0.95)
|
||||
})
|
||||
|
||||
expect(queryJobStatusMock).toHaveBeenCalledTimes(2)
|
||||
expect(sleepMock).toHaveBeenCalledWith(2000)
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -93,6 +93,7 @@ const ConfigParamModal: FC<Props> = ({
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
/* v8 ignore next -- callback dispatch depends on react-slider drag mechanics that are flaky in jsdom. @preserve */
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
score_threshold: val / 100,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { Features } from '../../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import ConversationOpener from '../index'
|
||||
|
||||
@ -144,7 +144,9 @@ describe('ConversationOpener', () => {
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
|
||||
modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' })
|
||||
act(() => {
|
||||
modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' })
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
@ -184,4 +186,41 @@ describe('ConversationOpener', () => {
|
||||
// After leave, statement visible again
|
||||
expect(screen.getByText('Welcome!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should return early from opener handler when disabled and hovered', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
expect(mockSetShowOpeningModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should run save and cancel callbacks without onChange', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/openingStatement\.writeOpener/))
|
||||
|
||||
const modalCall = mockSetShowOpeningModal.mock.calls[0][0]
|
||||
act(() => {
|
||||
modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated without callback' })
|
||||
modalCall.onCancelCallback()
|
||||
})
|
||||
|
||||
expect(mockSetShowOpeningModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should toggle feature switch without onChange callback', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -31,7 +31,25 @@ vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () =
|
||||
}))
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ReactSortable: ({
|
||||
children,
|
||||
list,
|
||||
setList,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
list: Array<{ id: number, name: string }>
|
||||
setList: (list: Array<{ id: number, name: string }>) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
data-testid="mock-sortable-apply"
|
||||
onClick={() => setList([...list].reverse())}
|
||||
>
|
||||
Apply Sort
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultData: OpeningStatement = {
|
||||
@ -168,6 +186,23 @@ describe('OpeningSettingModal', () => {
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onCancel when close icon receives non-action key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onSave with updated data when save is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
@ -507,4 +542,73 @@ describe('OpeningSettingModal', () => {
|
||||
expect(editor.textContent?.trim()).toBe('')
|
||||
expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with empty suggested questions when field is missing', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: undefined } as unknown as OpeningStatement}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('Question 2')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render prompt variable fallback key when name is empty', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
promptVariables={[{ key: 'account_id', name: '', type: 'string', required: true }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getPromptEditor()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save reordered suggested questions after sortable setList', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByTestId('mock-sortable-apply'))
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suggested_questions: ['Question 2', 'Question 1'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not save when confirm dialog action runs with empty opening statement', async () => {
|
||||
const onSave = vi.fn()
|
||||
const view = await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
|
||||
view.rerender(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: ' ' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByTestId('cancel-add'))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -34,6 +34,7 @@ const ConversationOpener = ({
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const handleOpenOpeningModal = useCallback(() => {
|
||||
/* v8 ignore next -- guarded path is not reachable in tests with a real disabled button because click is prevented at DOM level. @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
const {
|
||||
|
||||
@ -64,6 +64,14 @@ describe('FileUpload', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle without onChange callback', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
|
||||
@ -150,6 +150,17 @@ describe('SettingContent', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClose when close icon receives non-action key', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
fireEvent.keyDown(closeIconButton, { key: 'Escape' })
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked to close', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
@ -70,6 +70,14 @@ describe('ImageUpload', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle without onChange callback', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
|
||||
@ -3,6 +3,12 @@ import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FormGeneration from '../form-generation'
|
||||
|
||||
const { mockLocale } = vi.hoisted(() => ({ mockLocale: { value: 'en-US' } }))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => mockLocale.value,
|
||||
}))
|
||||
|
||||
const i18n = (en: string, zh = en): I18nText =>
|
||||
({ 'en-US': en, 'zh-Hans': zh }) as unknown as I18nText
|
||||
|
||||
@ -21,6 +27,7 @@ const createForm = (overrides: Partial<CodeBasedExtensionForm> = {}): CodeBasedE
|
||||
describe('FormGeneration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockLocale.value = 'en-US'
|
||||
})
|
||||
|
||||
it('should render text-input form fields', () => {
|
||||
@ -130,4 +137,22 @@ describe('FormGeneration', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
|
||||
})
|
||||
|
||||
it('should render zh-Hans labels for select field and options', () => {
|
||||
mockLocale.value = 'zh-Hans'
|
||||
const form = createForm({
|
||||
type: 'select',
|
||||
variable: 'model',
|
||||
label: i18n('Model', '模型'),
|
||||
options: [
|
||||
{ label: i18n('GPT-4', '智谱-4'), value: 'gpt-4' },
|
||||
{ label: i18n('GPT-3.5', '智谱-3.5'), value: 'gpt-3.5' },
|
||||
],
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('模型')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText(/placeholder\.select/))
|
||||
expect(screen.getByText('智谱-4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,6 +4,10 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import Moderation from '../index'
|
||||
|
||||
const { mockCodeBasedExtensionData } = vi.hoisted(() => ({
|
||||
mockCodeBasedExtensionData: [] as Array<{ name: string, label: Record<string, string> }>,
|
||||
}))
|
||||
|
||||
const mockSetShowModerationSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
@ -16,7 +20,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => ({ data: { data: [] } }),
|
||||
useCodeBasedExtensions: () => ({ data: { data: mockCodeBasedExtensionData } }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
@ -46,6 +50,7 @@ const renderWithProvider = (
|
||||
describe('Moderation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCodeBasedExtensionData.length = 0
|
||||
})
|
||||
|
||||
it('should render the moderation title', () => {
|
||||
@ -282,6 +287,25 @@ describe('Moderation', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from settings modal without onChange', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(() => modalCall.onCancelCallback()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from settings modal', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
@ -304,6 +328,25 @@ describe('Moderation', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from settings modal without onChange', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(() => modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should show code-based extension label for custom type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
@ -319,6 +362,41 @@ describe('Moderation', () => {
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show code-based extension label when custom type is configured', () => {
|
||||
mockCodeBasedExtensionData.push({
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Moderation', 'zh-Hans': '自定义审核' },
|
||||
})
|
||||
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'custom-ext',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('Custom Moderation')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show enable content text when both input and output moderation are disabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/feature\.moderation\.(allEnabled|inputEnabled|outputEnabled)/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open setting modal when clicking settings button while disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
moderation: {
|
||||
@ -351,6 +429,15 @@ describe('Moderation', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from enable modal without onChange', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(() => modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })).not.toThrow()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from enable modal and set enabled false', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
@ -364,6 +451,31 @@ describe('Moderation', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from enable modal without onChange', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(() => modalCall.onCancelCallback()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should disable moderation when toggled off without onChange', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should not show modal when enabling with existing type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ModerationContentConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as i18n from 'react-i18next'
|
||||
import ModerationContent from '../moderation-content'
|
||||
|
||||
const defaultConfig: ModerationContentConfig = {
|
||||
@ -124,4 +125,19 @@ describe('ModerationContent', () => {
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to empty placeholder when translation is empty', () => {
|
||||
const useTranslationSpy = vi.spyOn(i18n, 'useTranslation').mockReturnValue({
|
||||
t: (key: string) => key === 'feature.moderation.modal.content.placeholder' ? '' : key,
|
||||
i18n: { language: 'en-US' },
|
||||
} as unknown as ReturnType<typeof i18n.useTranslation>)
|
||||
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
showPreset: true,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', '')
|
||||
useTranslationSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as i18n from 'react-i18next'
|
||||
import ModerationSettingModal from '../moderation-setting-modal'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
@ -68,6 +69,13 @@ const defaultData: ModerationConfig = {
|
||||
|
||||
describe('ModerationSettingModal', () => {
|
||||
const onSave = vi.fn()
|
||||
const renderModal = async (ui: React.ReactNode) => {
|
||||
await act(async () => {
|
||||
render(ui)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCodeBasedExtensions = { data: { data: [] } }
|
||||
@ -93,7 +101,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -105,7 +113,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should render provider options', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -120,7 +128,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should show keywords textarea when keywords type is selected', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -134,7 +142,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -148,7 +156,7 @@ describe('ModerationSettingModal', () => {
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
@ -161,6 +169,60 @@ describe('ModerationSettingModal', () => {
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Enter key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: 'Enter' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Space key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: ' ' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onCancel when close icon receives non-action key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = document.querySelector('div[role="button"][tabindex="0"]') as HTMLElement
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
closeButton.focus()
|
||||
fireEvent.keyDown(closeButton, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when saving without inputs or outputs enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
@ -170,7 +232,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -194,7 +256,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -218,7 +280,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -239,7 +301,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should show api selector when api type is selected', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -251,7 +313,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should switch provider type when clicked', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -267,7 +329,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should update keywords on textarea change', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -282,7 +344,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should render moderation content sections', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -303,7 +365,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -327,7 +389,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -352,7 +414,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -380,7 +442,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -396,7 +458,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should toggle input moderation content', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -413,7 +475,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should toggle output moderation content', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -430,7 +492,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should select api extension via api selector', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -450,7 +512,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should save with openai_moderation type when configured', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{
|
||||
enabled: true,
|
||||
@ -473,7 +535,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should handle keyword truncation to 100 chars per line and 100 lines', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -499,7 +561,7 @@ describe('ModerationSettingModal', () => {
|
||||
outputs_config: { enabled: true, preset_response: 'output blocked' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
@ -518,7 +580,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should switch from keywords to api type', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -535,7 +597,7 @@ describe('ModerationSettingModal', () => {
|
||||
})
|
||||
|
||||
it('should handle empty lines in keywords', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -566,7 +628,7 @@ describe('ModerationSettingModal', () => {
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -594,7 +656,7 @@ describe('ModerationSettingModal', () => {
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -605,6 +667,10 @@ describe('ModerationSettingModal', () => {
|
||||
fireEvent.click(screen.getByText(/settings\.provider/))
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
|
||||
|
||||
const modalCall = mockSetShowAccountSettingModal.mock.calls[0][0]
|
||||
modalCall.onCancelCallback()
|
||||
expect(mockModelProvidersData.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save when OpenAI type is selected but not configured', async () => {
|
||||
@ -624,7 +690,7 @@ describe('ModerationSettingModal', () => {
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -650,7 +716,7 @@ describe('ModerationSettingModal', () => {
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -674,7 +740,7 @@ describe('ModerationSettingModal', () => {
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -699,7 +765,7 @@ describe('ModerationSettingModal', () => {
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
@ -727,7 +793,7 @@ describe('ModerationSettingModal', () => {
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -755,7 +821,7 @@ describe('ModerationSettingModal', () => {
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { api_url: 'https://example.com', inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -773,8 +839,40 @@ describe('ModerationSettingModal', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('should update code-based extension form value and save updated config', async () => {
|
||||
mockCodeBasedExtensions = {
|
||||
data: {
|
||||
data: [{
|
||||
name: 'custom-ext',
|
||||
label: { 'en-US': 'Custom Extension', 'zh-Hans': '自定义扩展' },
|
||||
form_schema: [
|
||||
{ variable: 'api_url', label: { 'en-US': 'API URL', 'zh-Hans': 'API 地址' }, type: 'text-input', required: true, default: '', placeholder: 'Enter URL', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter URL'), { target: { value: 'https://changed.com' } })
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'custom-ext',
|
||||
config: expect.objectContaining({
|
||||
api_url: 'https://changed.com',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show doc link for api type', async () => {
|
||||
await render(
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
@ -784,4 +882,56 @@ describe('ModerationSettingModal', () => {
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.link/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback missing inputs_config to disabled in formatted save data', async () => {
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={{
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
api_based_extension_id: 'ext-fallback',
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
}}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api',
|
||||
config: expect.objectContaining({
|
||||
inputs_config: expect.objectContaining({ enabled: false }),
|
||||
outputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should fallback to empty translated strings for optional placeholders and titles', async () => {
|
||||
const useTranslationSpy = vi.spyOn(i18n, 'useTranslation').mockReturnValue({
|
||||
t: (key: string) => [
|
||||
'feature.moderation.modal.keywords.placeholder',
|
||||
'feature.moderation.modal.content.input',
|
||||
'feature.moderation.modal.content.output',
|
||||
].includes(key)
|
||||
? ''
|
||||
: key,
|
||||
i18n: { language: 'en-US' },
|
||||
} as unknown as ReturnType<typeof i18n.useTranslation>)
|
||||
|
||||
await renderModal(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getAllByRole('textbox')[0]
|
||||
expect(textarea).toHaveAttribute('placeholder', '')
|
||||
useTranslationSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@ -30,6 +30,7 @@ const Moderation = ({
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
|
||||
const handleOpenModerationSettingModal = () => {
|
||||
/* v8 ignore next -- guarded path is not reachable in tests with a real disabled button because click is prevented at DOM level. @preserve */
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
|
||||
@ -185,6 +185,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
/* v8 ignore next -- UI-invariant guard: same condition is used in Save button disabled logic, so when true handleSave has no user-triggerable invocation path. @preserve */
|
||||
if (localeData.type === 'openai_moderation' && !isOpenAIProviderConfigured)
|
||||
return
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Features } from '../../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
@ -12,6 +13,23 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('../voice-settings', () => ({
|
||||
default: ({
|
||||
open,
|
||||
onOpen,
|
||||
children,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpen: (open: boolean) => void
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div data-testid="voice-settings" data-open={open ? 'true' : 'false'}>
|
||||
<button data-testid="open-voice-settings" onClick={() => onOpen(true)}>open-voice-settings</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
@ -68,6 +86,12 @@ describe('TextToSpeech', () => {
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle without onChange callback', () => {
|
||||
renderWithProvider()
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show language and voice info when enabled and not hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy' },
|
||||
@ -97,6 +121,19 @@ describe('TextToSpeech', () => {
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide voice settings button after mouse leave', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(card)
|
||||
expect(screen.queryByText(/voice\.voiceSettings\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show autoPlay enabled text when autoPlay is enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled },
|
||||
@ -112,4 +149,16 @@ describe('TextToSpeech', () => {
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlayDisabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass open false to voice settings when disabled and modal is opened', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByTestId('open-voice-settings'))
|
||||
|
||||
expect(screen.getByTestId('voice-settings')).toHaveAttribute('data-open', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,6 +3,38 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../../context'
|
||||
import VoiceSettings from '../voice-settings'
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({
|
||||
children,
|
||||
placement,
|
||||
offset,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
placement?: string
|
||||
offset?: { mainAxis?: number }
|
||||
}) => (
|
||||
<div
|
||||
data-testid="voice-settings-portal"
|
||||
data-placement={placement}
|
||||
data-main-axis={offset?.mainAxis}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<div data-testid="voice-settings-trigger" onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
useParams: () => ({ appId: 'test-app-id' }),
|
||||
@ -102,4 +134,19 @@ describe('VoiceSettings', () => {
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should use top placement and mainAxis 4 when placementLeft is false', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()} placementLeft={false}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
const portal = screen.getAllByTestId('voice-settings-portal')
|
||||
.find(item => item.hasAttribute('data-main-axis'))
|
||||
|
||||
expect(portal).toBeDefined()
|
||||
expect(portal).toHaveAttribute('data-placement', 'top')
|
||||
expect(portal).toHaveAttribute('data-main-axis', '4')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user