mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add tests for base > features (#32397)
Co-authored-by: sahil <sahil@infocusp.com>
This commit is contained in:
69
web/app/components/base/features/context.spec.tsx
Normal file
69
web/app/components/base/features/context.spec.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useContext } from 'react'
|
||||
import { FeaturesContext, FeaturesProvider } from './context'
|
||||
|
||||
const TestConsumer = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
if (!store)
|
||||
return <div>no store</div>
|
||||
|
||||
const { features } = store.getState()
|
||||
return <div role="status">{features.moreLikeThis?.enabled ? 'enabled' : 'disabled'}</div>
|
||||
}
|
||||
|
||||
describe('FeaturesProvider', () => {
|
||||
it('should provide store to children when FeaturesProvider wraps them', () => {
|
||||
render(
|
||||
<FeaturesProvider>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
|
||||
it('should provide initial features state when features prop is provided', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{ moreLikeThis: { enabled: true } }}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('enabled')
|
||||
})
|
||||
|
||||
it('should maintain the same store reference across re-renders', () => {
|
||||
const storeRefs: Array<ReturnType<typeof useContext>> = []
|
||||
|
||||
const StoreRefCollector = () => {
|
||||
const store = useContext(FeaturesContext)
|
||||
storeRefs.push(store)
|
||||
return null
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<FeaturesProvider>
|
||||
<StoreRefCollector />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(storeRefs[0]).toBe(storeRefs[1])
|
||||
})
|
||||
|
||||
it('should handle empty features object', () => {
|
||||
render(
|
||||
<FeaturesProvider features={{}}>
|
||||
<TestConsumer />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('disabled')
|
||||
})
|
||||
})
|
||||
63
web/app/components/base/features/hooks.spec.ts
Normal file
63
web/app/components/base/features/hooks.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesContext } from './context'
|
||||
import { useFeatures, useFeaturesStore } from './hooks'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
describe('useFeatures', () => {
|
||||
it('should return selected state from the store when useFeatures is called with selector', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: { moreLikeThis: { enabled: true } },
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => s.features.moreLikeThis?.enabled),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('should throw error when used outside FeaturesContext.Provider', () => {
|
||||
// Act & Assert
|
||||
expect(() => {
|
||||
renderHook(() => useFeatures(s => s.features))
|
||||
}).toThrow('Missing FeaturesContext.Provider in the tree')
|
||||
})
|
||||
|
||||
it('should return undefined when feature does not exist', () => {
|
||||
const store = createFeaturesStore({ features: {} })
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useFeatures(s => (s.features as Record<string, unknown>).nonexistent as boolean | undefined),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFeaturesStore', () => {
|
||||
it('should return the store from context when used within provider', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(FeaturesContext.Provider, { value: store }, children)
|
||||
|
||||
const { result } = renderHook(() => useFeaturesStore(), { wrapper })
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
|
||||
it('should return null when used outside provider', () => {
|
||||
const { result } = renderHook(() => useFeaturesStore())
|
||||
|
||||
expect(result.current).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AnnotationCtrlButton from './annotation-ctrl-button'
|
||||
|
||||
const mockSetShowAnnotationFullModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAnnotationFullModal: mockSetShowAnnotationFullModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockAnnotatedResponseUsage = 5
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { get annotatedResponse() { return mockAnnotatedResponseUsage } },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockAddAnnotation = vi.fn().mockResolvedValue({
|
||||
id: 'annotation-1',
|
||||
account: { name: 'Test User' },
|
||||
})
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
addAnnotation: (...args: unknown[]) => mockAddAnnotation(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
describe('AnnotationCtrlButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotatedResponseUsage = 5
|
||||
})
|
||||
|
||||
it('should render edit button when cached', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onEdit when edit button is clicked', () => {
|
||||
const onEdit = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={true}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onEdit).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render add button when not cached and has answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render any button when not cached and no answer', () => {
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer=""
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call addAnnotation and onAdded when add button is clicked', async () => {
|
||||
const onAdded = vi.fn()
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
messageId="msg-1"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={onAdded}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddAnnotation).toHaveBeenCalledWith('test-app', {
|
||||
message_id: 'msg-1',
|
||||
question: 'test query',
|
||||
answer: 'test answer',
|
||||
})
|
||||
expect(onAdded).toHaveBeenCalledWith('annotation-1', 'Test User')
|
||||
})
|
||||
})
|
||||
|
||||
it('should show annotation full modal when annotation limit is reached', () => {
|
||||
mockAnnotatedResponseUsage = 100
|
||||
|
||||
render(
|
||||
<AnnotationCtrlButton
|
||||
appId="test-app"
|
||||
cached={false}
|
||||
query="test query"
|
||||
answer="test answer"
|
||||
onAdded={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(mockSetShowAnnotationFullModal).toHaveBeenCalled()
|
||||
expect(mockAddAnnotation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,415 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import ConfigParamModal from './config-param-modal'
|
||||
|
||||
let mockHooksReturn: {
|
||||
modelList: { provider: { provider: string }, models: { model: string }[] }[]
|
||||
defaultModel: { provider: { provider: string }, model: string } | undefined
|
||||
currentModel: boolean | undefined
|
||||
} = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => mockHooksReturn,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel, onSelect }: { defaultModel?: { provider: string, model: string }, onSelect: (val: { provider: string, model: string }) => void }) => (
|
||||
<div data-testid="model-selector" data-provider={defaultModel?.provider} data-model={defaultModel?.model}>
|
||||
Model Selector
|
||||
<button data-testid="select-model" onClick={() => onSelect({ provider: 'cohere', model: 'embed-english' })}>Select</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
const defaultAnnotationConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
}
|
||||
|
||||
describe('ConfigParamModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHooksReturn = {
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}
|
||||
})
|
||||
|
||||
it('should not render when isShow is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/initSetup/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render init title when isInit is true', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render config title when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render score slider', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model selector', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and confirm buttons', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display score threshold value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('0.90')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configConfirmBtn when isInit is false', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
isInit={false}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/initSetup\.configConfirmBtn/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSave with embedding model and score when save is clicked', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the confirm/save button
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should show error toast when embedding model is not set', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
|
||||
// Override hooks to return no default model and no valid current model
|
||||
mockHooksReturn = {
|
||||
modelList: [],
|
||||
defaultModel: undefined,
|
||||
currentModel: undefined,
|
||||
}
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
expect(Toast.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onHide when cancel is clicked and not loading', () => {
|
||||
const onHide = vi.fn()
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onHide).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render slider with expected bounds and current value', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should update embedding model when model selector is used', () => {
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click the select model button in mock
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Model selector should now show the new provider/model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'cohere')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'embed-english')
|
||||
})
|
||||
|
||||
it('should call onSave with updated score from annotation config', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={{
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0.95,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Save
|
||||
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.95,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onSave with updated model after model selector change', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change model
|
||||
fireEvent.click(screen.getByTestId('select-model'))
|
||||
|
||||
// Save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'cohere', embedding_model_name: 'embed-english' },
|
||||
0.9,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use default model when annotation config has no embedding model', () => {
|
||||
const configWithoutModel = {
|
||||
...defaultAnnotationConfig,
|
||||
embedding_model: undefined as unknown as typeof defaultAnnotationConfig.embedding_model,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutModel}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Model selector should be initialized with the default model
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-provider', 'openai')
|
||||
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
|
||||
})
|
||||
|
||||
it('should use ANNOTATION_DEFAULT score_threshold when config has no score_threshold', () => {
|
||||
const configWithoutThreshold = {
|
||||
...defaultAnnotationConfig,
|
||||
score_threshold: 0,
|
||||
}
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
annotationConfig={configWithoutThreshold}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should set loading state while saving', async () => {
|
||||
let resolveOnSave: () => void
|
||||
const onSave = vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveOnSave = resolve
|
||||
}))
|
||||
const onHide = vi.fn()
|
||||
|
||||
render(
|
||||
<ConfigParamModal
|
||||
appId="test-app"
|
||||
isShow={true}
|
||||
onHide={onHide}
|
||||
onSave={onSave}
|
||||
annotationConfig={defaultAnnotationConfig}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click save
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const saveBtn = buttons.find(b => b.textContent?.includes('initSetup'))
|
||||
fireEvent.click(saveBtn!)
|
||||
|
||||
// While loading, clicking cancel should not call onHide
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
expect(onHide).not.toHaveBeenCalled()
|
||||
|
||||
// Resolve the save
|
||||
resolveOnSave!()
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Item } from './config-param'
|
||||
|
||||
describe('ConfigParam Item', () => {
|
||||
it('should render title text', () => {
|
||||
render(
|
||||
<Item title="Score Threshold" tooltip="Tooltip text">
|
||||
<div>children</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Score Threshold')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<Item title="Title" tooltip="Tooltip">
|
||||
<div data-testid="child-content">Child</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip icon', () => {
|
||||
render(
|
||||
<Item title="Title" tooltip="Tooltip text">
|
||||
<div>children</div>
|
||||
</Item>,
|
||||
)
|
||||
|
||||
// Tooltip component renders an icon next to the title
|
||||
expect(screen.getByText(/Title/)).toBeInTheDocument()
|
||||
// The Tooltip component is rendered as a sibling, confirming the tooltip prop is used
|
||||
expect(screen.getByText(/Title/).closest('div')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import AnnotationReply from './index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
}))
|
||||
|
||||
let mockIsShowAnnotationConfigInit = false
|
||||
let mockIsShowAnnotationFullModal = false
|
||||
const mockHandleEnableAnnotation = vi.fn().mockResolvedValue(undefined)
|
||||
const mockHandleDisableAnnotation = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSetIsShowAnnotationConfigInit = vi.fn((v: boolean) => {
|
||||
mockIsShowAnnotationConfigInit = v
|
||||
})
|
||||
const mockSetIsShowAnnotationFullModal = vi.fn((v: boolean) => {
|
||||
mockIsShowAnnotationFullModal = v
|
||||
})
|
||||
|
||||
let capturedSetAnnotationConfig: ((config: Record<string, unknown>) => void) | null = null
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/use-annotation-config', () => ({
|
||||
default: ({ setAnnotationConfig }: { setAnnotationConfig: (config: Record<string, unknown>) => void }) => {
|
||||
capturedSetAnnotationConfig = setAnnotationConfig
|
||||
return {
|
||||
handleEnableAnnotation: mockHandleEnableAnnotation,
|
||||
handleDisableAnnotation: mockHandleDisableAnnotation,
|
||||
get isShowAnnotationConfigInit() { return mockIsShowAnnotationConfigInit },
|
||||
setIsShowAnnotationConfigInit: mockSetIsShowAnnotationConfigInit,
|
||||
get isShowAnnotationFullModal() { return mockIsShowAnnotationFullModal },
|
||||
setIsShowAnnotationFullModal: mockSetIsShowAnnotationFullModal,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
|
||||
default: ({ show, onHide }: { show: boolean, onHide: () => void }) => (
|
||||
show
|
||||
? (
|
||||
<div data-testid="annotation-full-modal">
|
||||
<button data-testid="full-hide" onClick={onHide}>Hide</button>
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
ANNOTATION_DEFAULT: { score_threshold: 0.9 },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: () => (
|
||||
<div data-testid="model-selector">Model Selector</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<AnnotationReply disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AnnotationReply', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsShowAnnotationConfigInit = false
|
||||
mockIsShowAnnotationFullModal = false
|
||||
capturedSetAnnotationConfig = null
|
||||
})
|
||||
|
||||
it('should render the annotation reply title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setIsShowAnnotationConfigInit when switch is toggled on', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call handleDisableAnnotation when switch is toggled off', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockHandleDisableAnnotation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show score threshold and embedding model when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when score threshold is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show buttons when hovering over enabled feature', () => {
|
||||
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)
|
||||
|
||||
expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.cacheManagement/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setIsShowAnnotationConfigInit when params button is clicked', () => {
|
||||
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(/operation\.params/))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should navigate to annotations page when cache management is clicked', () => {
|
||||
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/test-app-id/annotations')
|
||||
})
|
||||
|
||||
it('should show config param modal when isShowAnnotationConfigInit is true', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/initSetup\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide config modal when hide is clicked', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call handleEnableAnnotation when config save is clicked', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show annotation full modal when isShowAnnotationFullModal is true', () => {
|
||||
mockIsShowAnnotationFullModal = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide annotation full modal when hide is clicked', () => {
|
||||
mockIsShowAnnotationFullModal = true
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByTestId('full-hide'))
|
||||
|
||||
expect(mockSetIsShowAnnotationFullModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call handleEnableAnnotation and hide config modal on save', async () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText(/initSetup\.confirmBtn/))
|
||||
|
||||
// handleEnableAnnotation should be called with embedding model and score
|
||||
expect(mockHandleEnableAnnotation).toHaveBeenCalledWith(
|
||||
{ embedding_provider_name: 'openai', embedding_model_name: 'text-embedding-ada-002' },
|
||||
0.9,
|
||||
)
|
||||
|
||||
// After save resolves, config init should be hidden
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetIsShowAnnotationConfigInit).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should update features and call onChange when updateAnnotationReply is invoked', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// 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',
|
||||
},
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update features without calling onChange when onChange is not provided', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Should not throw when onChange is not provided
|
||||
expect(capturedSetAnnotationConfig).not.toBeNull()
|
||||
expect(() => {
|
||||
capturedSetAnnotationConfig!({
|
||||
enabled: true,
|
||||
score_threshold: 0.7,
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
annotationReply: {
|
||||
enabled: true,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Before hover, info is visible
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
|
||||
const card = screen.getByText(/feature\.annotation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// After hover, buttons shown instead of info
|
||||
expect(screen.getByText(/operation\.params/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('0.9')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
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.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText('0.9')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass isInit prop to ConfigParamModal', () => {
|
||||
mockIsShowAnnotationConfigInit = true
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/initSetup\.confirmBtn/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/initSetup\.configConfirmBtn/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show annotation full modal when isShowAnnotationFullModal is false', () => {
|
||||
mockIsShowAnnotationFullModal = false
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Slider from './index'
|
||||
|
||||
describe('BaseSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the slider component', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the formatted value in the thumb', () => {
|
||||
render(<Slider value={85} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.85')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default min/max/step when not provided', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('should use custom min/max/step when provided', () => {
|
||||
render(<Slider value={90} min={80} max={100} step={5} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should handle NaN value as 0', () => {
|
||||
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
})
|
||||
|
||||
it('should pass disabled prop', () => {
|
||||
render(<Slider value={50} disabled onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ScoreSlider from './index'
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
|
||||
default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
|
||||
<input
|
||||
type="range"
|
||||
data-testid="slider"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ScoreSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the slider', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.8')).toBeInTheDocument()
|
||||
expect(screen.getByText('1.0')).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.easyMatch/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.annotation\.scoreThreshold\.accurateMatch/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom className', () => {
|
||||
const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />)
|
||||
|
||||
// Verifying the component renders successfully with a custom className
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should pass value to the slider', () => {
|
||||
render(<ScoreSlider value={95} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('slider')).toHaveValue('95')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PageType } from './type'
|
||||
|
||||
describe('PageType', () => {
|
||||
it('should have log and annotation values', () => {
|
||||
expect(PageType.log).toBe('log')
|
||||
expect(PageType.annotation).toBe('annotation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useAnnotationConfig from './use-annotation-config'
|
||||
|
||||
let mockIsAnnotationFull = false
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: {
|
||||
usage: { annotatedResponse: mockIsAnnotationFull ? 100 : 5 },
|
||||
total: { annotatedResponse: 100 },
|
||||
},
|
||||
enableBilling: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
updateAnnotationStatus: vi.fn().mockResolvedValue({ job_id: 'test-job-id' }),
|
||||
queryAnnotationJobStatus: vi.fn().mockResolvedValue({ job_status: 'completed' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils', () => ({
|
||||
sleep: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
describe('useAnnotationConfig', () => {
|
||||
const defaultConfig: AnnotationReplyConfig = {
|
||||
id: 'test-id',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsAnnotationFull = false
|
||||
})
|
||||
|
||||
it('should initialize with annotation config init hidden', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
expect(result.current.isShowAnnotationFullModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should show annotation config init modal', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(true)
|
||||
})
|
||||
|
||||
it('should hide annotation config init modal', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(false)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable annotation and update config', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
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(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(true)
|
||||
expect(updatedConfig.embedding_model.embedding_model_name).toBe('text-embedding-3-small')
|
||||
})
|
||||
|
||||
it('should disable annotation and update config', async () => {
|
||||
const enabledConfig = { ...defaultConfig, enabled: true }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: enabledConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDisableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should not disable when already disabled', async () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleDisableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-ada-002',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set score threshold', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setScore(0.85)
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.score_threshold).toBe(0.85)
|
||||
})
|
||||
|
||||
it('should set score and embedding model together', () => {
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setScore(0.95, {
|
||||
embedding_provider_name: 'cohere',
|
||||
embedding_model_name: 'embed-english',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.score_threshold).toBe(0.95)
|
||||
expect(updatedConfig.embedding_model.embedding_provider_name).toBe('cohere')
|
||||
})
|
||||
|
||||
it('should show annotation full modal instead of config init when annotation is full', () => {
|
||||
mockIsAnnotationFull = true
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: defaultConfig,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setIsShowAnnotationConfigInit(true)
|
||||
})
|
||||
|
||||
expect(result.current.isShowAnnotationFullModal).toBe(true)
|
||||
expect(result.current.isShowAnnotationConfigInit).toBe(false)
|
||||
})
|
||||
|
||||
it('should not enable annotation when annotation is full', async () => {
|
||||
mockIsAnnotationFull = true
|
||||
const setAnnotationConfig = vi.fn()
|
||||
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',
|
||||
})
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set default score_threshold when enabling without one', async () => {
|
||||
const configWithoutThreshold = { ...defaultConfig, score_threshold: undefined as unknown as number }
|
||||
const setAnnotationConfig = vi.fn()
|
||||
const { result } = renderHook(() => useAnnotationConfig({
|
||||
appId: 'test-app',
|
||||
annotationConfig: configWithoutThreshold,
|
||||
setAnnotationConfig,
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleEnableAnnotation({
|
||||
embedding_provider_name: 'openai',
|
||||
embedding_model_name: 'text-embedding-3-small',
|
||||
}, 0.95)
|
||||
})
|
||||
|
||||
expect(setAnnotationConfig).toHaveBeenCalled()
|
||||
const updatedConfig = setAnnotationConfig.mock.calls[0][0]
|
||||
expect(updatedConfig.enabled).toBe(true)
|
||||
expect(updatedConfig.score_threshold).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import Citation from './citation'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<Citation disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Citation', () => {
|
||||
it('should render the citation feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ConversationOpener from './index'
|
||||
|
||||
const mockSetShowOpeningModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowOpeningModal: mockSetShowOpeningModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ConversationOpener disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConversationOpener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the conversation opener title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show opening statement when enabled and not hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Welcome to the app!' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('Welcome to the app!')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show placeholder when enabled but no opening statement', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: '' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/openingStatement\.placeholder/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit button when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Hello' },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open modal when edit button is clicked', () => {
|
||||
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/))
|
||||
|
||||
expect(mockSetShowOpeningModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open modal when disabled', () => {
|
||||
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 pass opening data to modal', () => {
|
||||
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]
|
||||
expect(modalCall.payload).toBeDefined()
|
||||
expect(modalCall.onSaveCallback).toBeDefined()
|
||||
expect(modalCall.onCancelCallback).toBeDefined()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback and update features', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
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]
|
||||
modalCall.onSaveCallback({ enabled: true, opening_statement: 'Updated' })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
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]
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show info and hide when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
opening: { enabled: true, opening_statement: 'Welcome!' },
|
||||
})
|
||||
|
||||
// Before hover, opening statement visible
|
||||
expect(screen.getByText('Welcome!')).toBeInTheDocument()
|
||||
|
||||
const card = screen.getByText(/feature\.conversationOpener\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// After hover, button visible, statement hidden
|
||||
expect(screen.getByText(/openingStatement\.writeOpener/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
// After leave, statement visible again
|
||||
expect(screen.getByText('Welcome!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import OpeningSettingModal from './modal'
|
||||
|
||||
const getPromptEditor = () => {
|
||||
const editor = document.querySelector('[data-lexical-editor="true"]')
|
||||
expect(editor).toBeInTheDocument()
|
||||
return editor as HTMLElement
|
||||
}
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
checkKeys: (_keys: string[]) => ({ isValid: true }),
|
||||
getNewVar: (key: string, type: string) => ({ key, name: key, type, required: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-prompt/confirm-add-var', () => ({
|
||||
default: ({ varNameArr, onConfirm, onCancel }: {
|
||||
varNameArr: string[]
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="confirm-add-var">
|
||||
<span>{varNameArr.join(',')}</span>
|
||||
<button data-testid="confirm-add" onClick={onConfirm}>Confirm</button>
|
||||
<button data-testid="cancel-add" onClick={onCancel}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
const defaultData: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}
|
||||
|
||||
const createMockInputVar = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('OpeningSettingModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the opening statement in the editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getPromptEditor()).toHaveTextContent('Hello, how can I help?')
|
||||
})
|
||||
|
||||
it('should render suggested questions', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Enter key', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
|
||||
const closeButton = screen.getByTestId('close-modal')
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon receives Space 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: ' ' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSave with updated data when save is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'Hello, how can I help?',
|
||||
suggested_questions: ['Question 1', 'Question 2'],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable save when opening statement is empty', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/).closest('button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should add a new suggested question', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Before adding: 2 existing questions
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText(/variableConfig\.addOption/))
|
||||
|
||||
// After adding: the 2 existing questions still present plus 1 new empty one
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Question 2')).toBeInTheDocument()
|
||||
// The new empty question renders as an input with empty value
|
||||
const allInputs = screen.getAllByDisplayValue('')
|
||||
expect(allInputs.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should delete a suggested question via save verification', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: ['Question 1'] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Question should be present initially
|
||||
expect(screen.getByDisplayValue('Question 1')).toBeInTheDocument()
|
||||
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
await userEvent.click(deleteIconWrapper!)
|
||||
|
||||
// After deletion, Question 1 should be gone
|
||||
expect(screen.queryByDisplayValue('Question 1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update a suggested question value', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, 'Updated Question')
|
||||
|
||||
expect(input).toHaveValue('Updated Question')
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables are not in prompt', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save without variable check when confirm cancel is clicked', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
await userEvent.click(screen.getByTestId('cancel-add'))
|
||||
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show question count', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Count is displayed as "2/10" across child elements
|
||||
expect(screen.getByText(/openingStatement\.openingQuestion/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onAutoAddPromptVariable when confirm add is clicked', async () => {
|
||||
const onAutoAddPromptVariable = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onAutoAddPromptVariable={onAutoAddPromptVariable}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
// Confirm add var dialog should appear
|
||||
await userEvent.click(screen.getByTestId('confirm-add'))
|
||||
|
||||
expect(onAutoAddPromptVariable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show add button when max questions reached', async () => {
|
||||
const questionsAtMax: OpeningStatement = {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello',
|
||||
suggested_questions: Array.from({ length: 10 }, (_, i) => `Q${i + 1}`),
|
||||
}
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={questionsAtMax}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/variableConfig\.addOption/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply and remove focused styling on question input focus/blur', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = input.parentElement
|
||||
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
|
||||
await userEvent.click(input)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-active')
|
||||
|
||||
// Tab press to blur
|
||||
await userEvent.tab()
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('should apply and remove deleting styling on delete icon hover', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const questionInput = screen.getByDisplayValue('Question 1') as HTMLInputElement
|
||||
const questionRow = questionInput.parentElement
|
||||
const deleteIconWrapper = screen.getByTestId('delete-question-Question 1').parentElement
|
||||
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
expect(deleteIconWrapper).toBeTruthy()
|
||||
|
||||
await userEvent.hover(deleteIconWrapper!)
|
||||
expect(questionRow).toHaveClass('border-components-input-border-destructive')
|
||||
|
||||
await userEvent.unhover(deleteIconWrapper!)
|
||||
expect(questionRow).not.toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle save with empty suggested questions', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, suggested_questions: [] }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
suggested_questions: [],
|
||||
}))
|
||||
})
|
||||
|
||||
it('should not save when opening statement is only whitespace', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: ' ' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match prompt variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
promptVariables={[{ key: 'name', name: 'Name', type: 'string', required: true }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable is in promptVariables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip variable check when variables match workflow variables', async () => {
|
||||
const onSave = vi.fn()
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{name}}' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
// Variable matches workflow variables, so no confirm dialog
|
||||
expect(screen.queryByTestId('confirm-add-var')).not.toBeInTheDocument()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show confirm dialog when variables not in workflow variables', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'Hello {{unknown}}' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
workflowVariables={[createMockInputVar()]}
|
||||
/>,
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(screen.getByTestId('confirm-add-var')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use updated opening statement after prop changes', async () => {
|
||||
const onSave = vi.fn()
|
||||
const view = await render(
|
||||
<OpeningSettingModal
|
||||
data={defaultData}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: 'New greeting!' }}
|
||||
onSave={onSave}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await Promise.resolve()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opening_statement: 'New greeting!',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should render empty opening statement with placeholder in editor', async () => {
|
||||
await render(
|
||||
<OpeningSettingModal
|
||||
data={{ ...defaultData, opening_statement: '' }}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const editor = getPromptEditor()
|
||||
expect(editor.textContent?.trim()).toBe('')
|
||||
expect(screen.getByText('appDebug.openingStatement.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
@@ -139,7 +138,7 @@ const OpeningSettingModal = ({
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<RiDraggable className="handle h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<span className="handle i-ri-draggable h-4 w-4 cursor-grab text-text-quaternary" />
|
||||
<input
|
||||
type="input"
|
||||
value={question || ''}
|
||||
@@ -166,7 +165,7 @@ const OpeningSettingModal = ({
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
onMouseLeave={() => setDeletingID(null)}
|
||||
>
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
<span className="i-ri-delete-bin-line h-3.5 w-3.5" data-testid={`delete-question-${question}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -175,10 +174,10 @@ const OpeningSettingModal = ({
|
||||
{tempSuggestedQuestions.length < MAX_QUESTION_NUM && (
|
||||
<div
|
||||
onClick={() => { setTempSuggestedQuestions([...tempSuggestedQuestions, '']) }}
|
||||
className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover"
|
||||
className="mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<div className="system-sm-medium text-[13px]">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
<span className="i-ri-add-line h-4 w-4" />
|
||||
<div className="text-[13px] system-sm-medium">{t('variableConfig.addOption', { ns: 'appDebug' })}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -192,12 +191,26 @@ const OpeningSettingModal = ({
|
||||
className="!mt-14 !w-[640px] !max-w-none !bg-components-panel-bg-blur !p-6"
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onCancel}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('feature.conversationOpener.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onCancel}
|
||||
data-testid="close-modal"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8 flex gap-2">
|
||||
<div className="mt-1.5 h-8 w-8 shrink-0 rounded-lg border-components-panel-border bg-util-colors-orange-dark-orange-dark-500 p-1.5">
|
||||
<RiAsterisk className="h-5 w-5 text-text-primary-on-surface" />
|
||||
<span className="i-ri-asterisk h-5 w-5 text-text-primary-on-surface" />
|
||||
</div>
|
||||
<div className="grow rounded-2xl border-t border-divider-subtle bg-chat-bubble-bg p-3 shadow-xs">
|
||||
<PromptEditor
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DialogWrapper from './dialog-wrapper'
|
||||
|
||||
describe('DialogWrapper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when show is true', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render children when show is false', () => {
|
||||
render(
|
||||
<DialogWrapper show={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply workflow styles by default', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('rounded-l-2xl')
|
||||
expect(wrapper).not.toHaveClass('rounded-2xl')
|
||||
})
|
||||
|
||||
it('should apply non-workflow styles when inWorkflow is false', () => {
|
||||
render(
|
||||
<DialogWrapper show inWorkflow={false}>
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const content = screen.getByTestId('content')
|
||||
const panel = content.parentElement
|
||||
const layoutContainer = screen.getByTestId('dialog-layout-container')
|
||||
|
||||
expect(layoutContainer).toHaveClass('pr-2')
|
||||
expect(layoutContainer).toHaveClass('pt-[64px]')
|
||||
expect(layoutContainer).not.toHaveClass('pt-[112px]')
|
||||
|
||||
expect(panel).toHaveClass('rounded-2xl')
|
||||
expect(panel).toHaveClass('border-[0.5px]')
|
||||
expect(panel).not.toHaveClass('rounded-l-2xl')
|
||||
})
|
||||
|
||||
it('should accept custom className', () => {
|
||||
render(
|
||||
<DialogWrapper show className="custom-class">
|
||||
<div data-testid="content">Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
const wrapper = screen.getByTestId('content').parentElement
|
||||
expect(wrapper).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Close behavior', () => {
|
||||
it('should call onClose when escape is pressed', async () => {
|
||||
const onClose = vi.fn()
|
||||
|
||||
render(
|
||||
<DialogWrapper show onClose={onClose}>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not throw when escape is pressed without onClose', () => {
|
||||
render(
|
||||
<DialogWrapper show>
|
||||
<div>Content</div>
|
||||
</DialogWrapper>,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,12 +33,12 @@ const DialogWrapper = ({
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0">
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')}>
|
||||
<div className={cn('flex min-h-full flex-col items-end justify-center pb-2', inWorkflow ? 'pt-[112px]' : 'pr-2 pt-[64px]')} data-testid="dialog-layout-container">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={cn(
|
||||
'relative flex h-0 w-[420px] grow flex-col overflow-hidden border-components-panel-border bg-components-panel-bg-alt p-0 text-left align-middle shadow-xl transition-all',
|
||||
inWorkflow ? 'rounded-l-2xl border-b-[0.5px] border-l-[0.5px] border-t-[0.5px]' : 'rounded-2xl border-[0.5px]',
|
||||
'data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
|
||||
'data-[leave]:scale-95 data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Features } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import FeatureBar from './feature-bar'
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: {
|
||||
isChatMode?: boolean
|
||||
showFileUpload?: boolean
|
||||
disabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
hideEditEntrance?: boolean
|
||||
} = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<FeatureBar {...props} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FeatureBar', () => {
|
||||
describe('Empty State', () => {
|
||||
it('should render empty state when no features are enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeatureBarClick when empty state is clicked', () => {
|
||||
const onFeatureBarClick = vi.fn()
|
||||
|
||||
renderWithProvider({ onFeatureBarClick })
|
||||
fireEvent.click(screen.getByText(/feature\.bar\.empty/))
|
||||
|
||||
expect(onFeatureBarClick).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Enabled Features', () => {
|
||||
it('should show enabled text when moreLikeThis is enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show manage button when features are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.manage/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide manage button when hideEditEntrance is true', () => {
|
||||
renderWithProvider({ hideEditEntrance: true }, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.queryByText(/feature\.bar\.manage/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeatureBarClick when manage button is clicked', () => {
|
||||
const onFeatureBarClick = vi.fn()
|
||||
|
||||
renderWithProvider({ onFeatureBarClick }, {
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
fireEvent.click(screen.getByText(/feature\.bar\.manage/))
|
||||
|
||||
expect(onFeatureBarClick).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat Mode Features', () => {
|
||||
it('should show enabled text when citation is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
citation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state when citation is enabled but not in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: false }, {
|
||||
citation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when opening is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
opening: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when file is enabled with showFileUpload', () => {
|
||||
renderWithProvider({ showFileUpload: true }, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state when file is enabled but showFileUpload is false', () => {
|
||||
renderWithProvider({ showFileUpload: false }, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.empty/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when speech2text is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
speech2text: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when text2speech is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when moderation is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
moderation: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when suggested is enabled', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
suggested: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show enabled text when annotationReply is enabled in chat mode', () => {
|
||||
renderWithProvider({ isChatMode: true }, {
|
||||
annotationReply: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.bar\.enableText/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FeatureCard from './feature-card'
|
||||
|
||||
describe('FeatureCard', () => {
|
||||
const defaultProps = {
|
||||
icon: <div data-testid="icon">icon</div>,
|
||||
title: 'Test Feature',
|
||||
value: false,
|
||||
}
|
||||
|
||||
it('should render icon and title', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when provided', () => {
|
||||
render(<FeatureCard {...defaultProps} description="A test description" />)
|
||||
|
||||
expect(screen.getByText(/A test description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render description when not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByText(/description/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when switch is toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render tooltip when provided', () => {
|
||||
render(<FeatureCard {...defaultProps} tooltip="Helpful tip" />)
|
||||
|
||||
// Tooltip text is passed as prop, verifying the component renders with it
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render tooltip when not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
// Without tooltip, the title should still render
|
||||
expect(screen.getByText(/Test Feature/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children when provided', () => {
|
||||
render(
|
||||
<FeatureCard {...defaultProps}>
|
||||
<div data-testid="child-content">Child</div>
|
||||
</FeatureCard>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onMouseEnter when hovering', () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onMouseEnter={onMouseEnter} />)
|
||||
|
||||
const card = screen.getByText(/Test Feature/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onMouseLeave when mouse leaves', () => {
|
||||
const onMouseLeave = vi.fn()
|
||||
render(<FeatureCard {...defaultProps} onMouseLeave={onMouseLeave} />)
|
||||
|
||||
const card = screen.getByText(/Test Feature/).closest('[class]')!
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(onMouseLeave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
render(<FeatureCard {...defaultProps} disabled={true} />)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call onChange when onChange is not provided', () => {
|
||||
render(<FeatureCard {...defaultProps} />)
|
||||
|
||||
// Should not throw when switch is clicked without onChange
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import FileUpload from './index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<FileUpload disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the file upload title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image', 'document'],
|
||||
number_limits: 5,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('image,document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show number limits when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when no allowed file types', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open setting modal when settings is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show supported types label when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.fileUpload\.numberLimit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Info display should be hidden, settings button should appear
|
||||
expect(screen.queryByText(/feature\.fileUpload\.supportedTypes/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close setting modal when cancel is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.fileUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/feature\.fileUpload\.modalTitle/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import SettingContent from './setting-content'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/file-upload-setting', () => ({
|
||||
default: ({ payload, onChange }: { payload: Record<string, unknown>, onChange: (p: Record<string, unknown>) => void }) => (
|
||||
<div data-testid="file-upload-setting">
|
||||
<span data-testid="payload">{JSON.stringify(payload)}</span>
|
||||
<button
|
||||
data-testid="change-setting"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: ['document'],
|
||||
})}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
data-testid="clear-file-types"
|
||||
onClick={() => onChange({
|
||||
...payload,
|
||||
allowed_file_types: [],
|
||||
})}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/types', () => ({
|
||||
SupportUploadFileTypes: {
|
||||
image: 'image',
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
number_limits: 5,
|
||||
},
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { imageUpload?: boolean, onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<SettingContent
|
||||
imageUpload={props.imageUpload}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render file upload modal title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image upload modal title when imageUpload is true', () => {
|
||||
renderWithProvider({ imageUpload: true })
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render FileUploadSetting component with payload from file feature', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByTestId('file-upload-setting')).toBeInTheDocument()
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_extensions":[".jpg"]')
|
||||
expect(payload.textContent).toContain('"max_length":5')
|
||||
})
|
||||
|
||||
it('should use fallback payload values when file feature is undefined', () => {
|
||||
renderWithProvider({}, { file: undefined })
|
||||
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('"allowed_file_upload_methods":["local_file","remote_url"]')
|
||||
expect(payload.textContent).toContain('"allowed_file_types":["image"]')
|
||||
expect(payload.textContent).toContain('"max_length":3')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
expect(closeIconButton).toBeInTheDocument()
|
||||
if (!closeIconButton)
|
||||
throw new Error('Close icon button should exist')
|
||||
|
||||
fireEvent.click(closeIconButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when close icon receives Space key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeIconButton = screen.getByTestId('close-setting-modal')
|
||||
closeIconButton.focus()
|
||||
fireEvent.keyDown(closeIconButton, { key: ' ' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClose when cancel button is clicked to close', () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
// Use the cancel button to test the close behavior
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onChange when save is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when save is clicked without onChange', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => {
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should disable save button when allowed file types are empty', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-file-types'))
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /operation\.save/ })
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update temp payload when FileUploadSetting onChange is called', () => {
|
||||
renderWithProvider()
|
||||
|
||||
// Click the change button in mock FileUploadSetting to trigger setTempPayload
|
||||
fireEvent.click(screen.getByTestId('change-setting'))
|
||||
|
||||
// The payload should be updated with the new allowed_file_types
|
||||
const payload = screen.getByTestId('payload')
|
||||
expect(payload.textContent).toContain('document')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@@ -58,8 +57,22 @@ const SettingContent = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary system-xl-semibold">{!imageUpload ? t('feature.fileUpload.modalTitle', { ns: 'appDebug' }) : t('feature.imageUpload.modalTitle', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onClose}
|
||||
data-testid="close-setting-modal"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<FileUploadSetting
|
||||
isMultiple
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Features } from '../../types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import FileUploadSettings from './setting-modal'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
number_limits: 5,
|
||||
},
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileUploadSettings (setting-modal)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children in trigger', () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={vi.fn()}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Upload Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render SettingContent in portal', async () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={vi.fn()}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.fileUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={onOpen}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Upload Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0][0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={false} onOpen={onOpen} disabled>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Upload Settings'))
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onOpen with false when cancel is clicked', async () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={onOpen}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should call onChange and close when save is clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={onOpen} onChange={onChange}>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.save/ }))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should pass imageUpload prop to SettingContent', async () => {
|
||||
renderWithProvider(
|
||||
<FileUploadSettings open={true} onOpen={vi.fn()} imageUpload>
|
||||
<button>Upload Settings</button>
|
||||
</FileUploadSettings>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import FollowUp from './follow-up'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<FollowUp disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('FollowUp', () => {
|
||||
it('should render the follow-up feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ImageUpload from './index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useFileUploadConfig: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ImageUpload disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ImageUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the image upload title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render LEGACY badge', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText('LEGACY')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show supported types when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('image')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show number limits when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open image upload setting modal when settings is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show supported types and number limit labels when enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.imageUpload\.numberLimit/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.queryByText(/feature\.imageUpload\.supportedTypes/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image'],
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.imageUpload\.supportedTypes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dash when no file types configured', () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close setting modal when cancel is clicked', async () => {
|
||||
renderWithProvider({}, {
|
||||
file: { enabled: true },
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.imageUpload\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.click(screen.getByText(/operation\.settings/))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/feature\.imageUpload\.modalTitle/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation\.cancel/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/feature\.imageUpload\.modalTitle/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import type { Features } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import NewFeaturePanel from './index'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: (type: string) => {
|
||||
if (type === 'speech2text' || type === 'tts')
|
||||
return { data: { provider: 'openai', model: 'whisper-1' } }
|
||||
return { data: null }
|
||||
},
|
||||
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
|
||||
modelList: [{ provider: { provider: 'openai' }, models: [{ model: 'text-embedding-ada-002' }] }],
|
||||
defaultModel: { provider: { provider: 'openai' }, model: 'text-embedding-ada-002' },
|
||||
currentModel: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
ModelTypeEnum: {
|
||||
speech2text: 'speech2text',
|
||||
tts: 'tts',
|
||||
textEmbedding: 'text-embedding',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: () => <div data-testid="model-selector">Model Selector</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => ({ data: undefined }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderPanel = (props: Partial<{
|
||||
show: boolean
|
||||
isChatMode: boolean
|
||||
disabled: boolean
|
||||
onChange: () => void
|
||||
onClose: () => void
|
||||
inWorkflow: boolean
|
||||
showFileUpload: boolean
|
||||
}> = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
<NewFeaturePanel
|
||||
show={props.show ?? true}
|
||||
isChatMode={props.isChatMode ?? true}
|
||||
disabled={props.disabled ?? false}
|
||||
onChange={props.onChange}
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
inWorkflow={props.inWorkflow}
|
||||
showFileUpload={props.showFileUpload}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('NewFeaturePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should not render when show is false', () => {
|
||||
renderPanel({ show: false })
|
||||
|
||||
expect(screen.queryByText(/common\.features/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render header with title and description when show is true', () => {
|
||||
renderPanel({ show: true })
|
||||
|
||||
expect(screen.getByText(/common\.featuresDescription/)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/common\.features/).length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chat Mode Features', () => {
|
||||
it('should render conversation opener in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.conversationOpener\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render follow-up in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.suggestedQuestionsAfterAnswer\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render citation in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.citation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render speech-to-text in chat mode when model is available', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render text-to-speech in chat mode when model is available', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render moderation in chat mode', () => {
|
||||
renderPanel({ isChatMode: true })
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Upload', () => {
|
||||
it('should render file upload in chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/feature\.fileUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render image upload in chat mode', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image upload in non-chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: false, showFileUpload: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.imageUpload\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render file upload when showFileUpload is false', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: false })
|
||||
|
||||
expect(screen.queryByText(/feature\.fileUpload\.title/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/feature\.imageUpload\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show file upload tip in chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.fileUploadTip/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show image upload legacy tip in non-chat mode with showFileUpload', () => {
|
||||
renderPanel({ isChatMode: false, showFileUpload: true })
|
||||
|
||||
expect(screen.getByText(/common\.ImageUploadLegacyTip/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MoreLikeThis Feature', () => {
|
||||
it('should render MoreLikeThis in non-chat, non-workflow mode', () => {
|
||||
renderPanel({ isChatMode: false, inWorkflow: false })
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render MoreLikeThis in chat mode', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: false })
|
||||
|
||||
expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render MoreLikeThis in workflow mode', () => {
|
||||
renderPanel({ isChatMode: false, inWorkflow: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.moreLikeThis\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Annotation Reply Feature', () => {
|
||||
it('should render AnnotationReply in chat mode when not in workflow', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: false })
|
||||
|
||||
expect(screen.getByText(/feature\.annotation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render AnnotationReply in workflow mode', () => {
|
||||
renderPanel({ isChatMode: true, inWorkflow: true })
|
||||
|
||||
expect(screen.queryByText(/feature\.annotation\.title/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show file upload tip when showFileUpload is false', () => {
|
||||
renderPanel({ isChatMode: true, showFileUpload: false })
|
||||
|
||||
expect(screen.queryByText(/common\.fileUploadTip/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { I18nText } from '@/i18n-config/language'
|
||||
import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import FormGeneration from './form-generation'
|
||||
|
||||
const i18n = (en: string, zh = en): I18nText =>
|
||||
({ 'en-US': en, 'zh-Hans': zh }) as unknown as I18nText
|
||||
|
||||
const createForm = (overrides: Partial<CodeBasedExtensionForm> = {}): CodeBasedExtensionForm => ({
|
||||
type: 'text-input',
|
||||
variable: 'api_key',
|
||||
label: i18n('API Key', 'API 密钥'),
|
||||
placeholder: 'Enter API key',
|
||||
required: true,
|
||||
options: [],
|
||||
default: '',
|
||||
max_length: 100,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('FormGeneration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render text-input form fields', () => {
|
||||
const form = createForm()
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when text input value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm()
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter API key'), {
|
||||
target: { value: 'my-key' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ api_key: 'my-key' })
|
||||
})
|
||||
|
||||
it('should render paragraph form fields', () => {
|
||||
const form = createForm({
|
||||
type: 'paragraph',
|
||||
variable: 'description',
|
||||
label: i18n('Description', '描述'),
|
||||
placeholder: 'Enter description',
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Description')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select form fields', () => {
|
||||
const form = createForm({
|
||||
type: 'select',
|
||||
variable: 'model',
|
||||
label: i18n('Model', '模型'),
|
||||
options: [
|
||||
{ label: i18n('GPT-4'), value: 'gpt-4' },
|
||||
{ label: i18n('GPT-3.5'), value: 'gpt-3.5' },
|
||||
],
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Model')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple forms', () => {
|
||||
const forms = [
|
||||
createForm({ variable: 'key1', label: i18n('Field 1', '字段1') }),
|
||||
createForm({ variable: 'key2', label: i18n('Field 2', '字段2'), type: 'paragraph' }),
|
||||
]
|
||||
render(<FormGeneration forms={forms} value={{}} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('Field 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Field 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display existing values', () => {
|
||||
const form = createForm()
|
||||
render(
|
||||
<FormGeneration
|
||||
forms={[form]}
|
||||
value={{ api_key: 'existing-key' }}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when paragraph textarea value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm({
|
||||
type: 'paragraph',
|
||||
variable: 'description',
|
||||
label: i18n('Description', '描述'),
|
||||
placeholder: 'Enter description',
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Enter description'), {
|
||||
target: { value: 'my description' },
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ description: 'my description' })
|
||||
})
|
||||
|
||||
it('should call onChange when select option is chosen', () => {
|
||||
const onChange = vi.fn()
|
||||
const form = createForm({
|
||||
type: 'select',
|
||||
variable: 'model',
|
||||
label: i18n('Model', '模型'),
|
||||
options: [
|
||||
{ label: i18n('GPT-4'), value: 'gpt-4' },
|
||||
{ label: i18n('GPT-3.5'), value: 'gpt-3.5' },
|
||||
],
|
||||
})
|
||||
render(<FormGeneration forms={[form]} value={{}} onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/placeholder\.select/))
|
||||
fireEvent.click(screen.getByText('GPT-4'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ model: 'gpt-4' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import Moderation from './index'
|
||||
|
||||
const mockSetShowModerationSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowModerationSettingModal: mockSetShowModerationSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useLocale: () => 'en-US',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => ({ data: { data: [] } }),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<Moderation disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Moderation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the moderation title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when not enabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open moderation setting modal when enabled without type', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show provider info when enabled with openai_moderation type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'openai_moderation',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show provider info when enabled with keywords type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show allEnabled when both inputs and outputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.allEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show inputEnabled when only inputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.inputEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show outputEnabled when only outputs are enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.outputEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show settings button when hovering over enabled feature', () => {
|
||||
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)
|
||||
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open moderation modal when settings button is clicked', () => {
|
||||
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/))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not open modal when disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
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/))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show api provider label when type is api', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable moderation and call onChange when switch is toggled off', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open modal with default config when enabling without existing type', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(mockSetShowModerationSettingModal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from modal and update features', () => {
|
||||
renderWithProvider()
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// Extract the onSaveCallback from the modal call
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
expect(modalCall.onSaveCallback).toBeDefined()
|
||||
expect(modalCall.onCancelCallback).toBeDefined()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from settings modal', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
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]
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from settings modal', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange }, {
|
||||
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]
|
||||
modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show code-based extension label for custom type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'custom-ext',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// For unknown types, falls back to codeBasedExtensionList label or '-'
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open setting modal when clicking settings button while disabled', () => {
|
||||
renderWithProvider({ disabled: true }, {
|
||||
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/))
|
||||
|
||||
// disabled check in handleOpenModerationSettingModal should prevent call
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onSaveCallback from enable modal and update features', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
// Execute the onSaveCallback
|
||||
modalCall.onSaveCallback({ enabled: true, type: 'keywords', config: {} })
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should invoke onCancelCallback from enable modal and set enabled false', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
const modalCall = mockSetShowModerationSettingModal.mock.calls[0][0]
|
||||
// Execute the onCancelCallback
|
||||
modalCall.onCancelCallback()
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not show modal when enabling with existing type', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: false,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
// When type already exists, handleChange's first if-branch is skipped
|
||||
// because features.moderation.type is already 'keywords'
|
||||
// It should NOT call setShowModerationSettingModal for init
|
||||
expect(mockSetShowModerationSettingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should hide info display when hovering over enabled feature', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
|
||||
// Info is visible before hover
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
// Info hidden, settings button shown
|
||||
expect(screen.getByText(/operation\.settings/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show info display again when mouse leaves', () => {
|
||||
renderWithProvider({}, {
|
||||
moderation: {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const card = screen.getByText(/feature\.moderation\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
fireEvent.mouseLeave(card)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.keywords/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ModerationContentConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModerationContent from './moderation-content'
|
||||
|
||||
const defaultConfig: ModerationContentConfig = {
|
||||
enabled: false,
|
||||
preset_response: '',
|
||||
}
|
||||
|
||||
const renderComponent = (props: Partial<{
|
||||
title: string
|
||||
info: string
|
||||
showPreset: boolean
|
||||
config: ModerationContentConfig
|
||||
onConfigChange: (config: ModerationContentConfig) => void
|
||||
}> = {}) => {
|
||||
const onConfigChange = props.onConfigChange ?? vi.fn()
|
||||
return render(
|
||||
<ModerationContent
|
||||
title={props.title ?? 'Test Title'}
|
||||
info={props.info}
|
||||
showPreset={props.showPreset}
|
||||
config={props.config ?? defaultConfig}
|
||||
onConfigChange={onConfigChange}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ModerationContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the title', () => {
|
||||
renderComponent({ title: 'Input Content' })
|
||||
|
||||
expect(screen.getByText('Input Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render info text when provided', () => {
|
||||
renderComponent({ info: 'Some info text' })
|
||||
|
||||
expect(screen.getByText('Some info text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render info when not provided', () => {
|
||||
renderComponent()
|
||||
|
||||
// When info is not provided, only the title "Test Title" should be shown
|
||||
expect(screen.getByText(/Test Title/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Some info text/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onConfigChange with enabled true when switch is toggled on', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
renderComponent({ onConfigChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({ ...defaultConfig, enabled: true })
|
||||
})
|
||||
|
||||
it('should show preset textarea when enabled and showPreset is true', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
showPreset: true,
|
||||
})
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.preset/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show preset textarea when showPreset is false', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
showPreset: false,
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onConfigChange when preset_response is changed', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
onConfigChange,
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test response' } })
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
preset_response: 'test response',
|
||||
})
|
||||
})
|
||||
|
||||
it('should truncate preset_response to 100 characters', () => {
|
||||
const onConfigChange = vi.fn()
|
||||
const longText = 'a'.repeat(150)
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: '' },
|
||||
onConfigChange,
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: longText } })
|
||||
|
||||
expect(onConfigChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
preset_response: 'a'.repeat(100),
|
||||
})
|
||||
})
|
||||
|
||||
it('should display character count', () => {
|
||||
renderComponent({
|
||||
config: { enabled: true, preset_response: 'hello' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,787 @@
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModerationSettingModal from './moderation-setting-modal'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
}),
|
||||
}))
|
||||
|
||||
let mockCodeBasedExtensions: { data: { data: Record<string, unknown>[] } } = { data: { data: [] } }
|
||||
let mockModelProvidersData: {
|
||||
data: { data: Record<string, unknown>[] }
|
||||
isPending: boolean
|
||||
refetch: ReturnType<typeof vi.fn>
|
||||
} = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCodeBasedExtensions: () => mockCodeBasedExtensions,
|
||||
useModelProviders: () => mockModelProvidersData,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/declarations', () => ({
|
||||
CustomConfigurationStatusEnum: { active: 'active' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/constants', () => ({
|
||||
ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
|
||||
default: ({ onChange }: { value: string, onChange: (v: string) => void }) => (
|
||||
<div data-testid="api-selector">
|
||||
<button data-testid="select-api" onClick={() => onChange('api-ext-1')}>Select API</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const defaultData: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'keywords',
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'Input blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
|
||||
describe('ModerationSettingModal', () => {
|
||||
const onSave = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCodeBasedExtensions = { data: { data: [] } }
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: 'paid',
|
||||
quota_configurations: [{ quota_type: 'paid', is_valid: true }],
|
||||
},
|
||||
custom_configuration: { status: 'active' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should render the modal title', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render provider options', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.provider\.openai/)).toBeInTheDocument()
|
||||
// Keywords text appears both as provider option and section label
|
||||
expect(screen.getAllByText(/feature\.moderation\.modal\.provider\.keywords/).length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText(/apiBasedExtension\.selector\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show keywords textarea when keywords type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('bad\nword')
|
||||
})
|
||||
|
||||
it('should render cancel and save buttons', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCancel when cancel is clicked', async () => {
|
||||
const onCancel = vi.fn()
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error when saving without inputs or outputs enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when keywords type has no keywords', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: '',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onSave with formatted data when valid', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'keywords',
|
||||
enabled: true,
|
||||
config: expect.objectContaining({
|
||||
keywords: 'bad\nword',
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show api selector when api type is selected', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch provider type when clicked', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on openai_moderation provider
|
||||
fireEvent.click(screen.getByText(/feature\.moderation\.modal\.provider\.openai/))
|
||||
|
||||
// The keywords textarea should no longer be visible since type changed
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update keywords on textarea change', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'new\nkeywords' } })
|
||||
|
||||
expect(textarea).toHaveValue('new\nkeywords')
|
||||
})
|
||||
|
||||
it('should render moderation content sections', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.input/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.content\.output/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when inputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error when api type has no api_based_extension_id', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with api_based_extension_id in formatted data for api type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
enabled: true,
|
||||
type: 'api',
|
||||
config: {
|
||||
api_based_extension_id: 'ext-1',
|
||||
inputs_config: { enabled: true, preset_response: '' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// api type doesn't require preset_response, so save should succeed
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'api',
|
||||
config: expect.objectContaining({
|
||||
api_based_extension_id: 'ext-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show error when outputs enabled but no preset_response for keywords type', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: false, preset_response: '' },
|
||||
outputs_config: { enabled: true, preset_response: '' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should toggle input moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[0])
|
||||
|
||||
expect(screen.queryAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should toggle output moderation content', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const switches = screen.getAllByRole('switch')
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(1)
|
||||
|
||||
fireEvent.click(switches[1])
|
||||
|
||||
expect(screen.getAllByPlaceholderText(/feature\.moderation\.modal\.content\.placeholder/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should select api extension via api selector', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('select-api'))
|
||||
|
||||
// Trigger save and confirm the chosen extension id is passed through
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ api_based_extension_id: 'api-ext-1' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with openai_moderation type when configured', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{
|
||||
enabled: true,
|
||||
type: 'openai_moderation',
|
||||
config: {
|
||||
inputs_config: { enabled: true, preset_response: 'blocked' },
|
||||
outputs_config: { enabled: false, preset_response: '' },
|
||||
},
|
||||
}}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'openai_moderation',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle keyword truncation to 100 chars per line and 100 lines', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)
|
||||
// Create a long keyword that exceeds 100 chars
|
||||
const longWord = 'a'.repeat(150)
|
||||
fireEvent.change(textarea, { target: { value: longWord } })
|
||||
|
||||
// Value should be truncated to 100 chars
|
||||
expect((textarea as HTMLTextAreaElement).value.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('should save with formatted outputs_config when both enabled', async () => {
|
||||
const data: ModerationConfig = {
|
||||
...defaultData,
|
||||
config: {
|
||||
keywords: 'test',
|
||||
inputs_config: { enabled: true, preset_response: 'input blocked' },
|
||||
outputs_config: { enabled: true, preset_response: 'output blocked' },
|
||||
},
|
||||
}
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={data}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
inputs_config: expect.objectContaining({ enabled: true }),
|
||||
outputs_config: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should switch from keywords to api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click api provider
|
||||
fireEvent.click(screen.getByText(/apiBasedExtension\.selector\.title/))
|
||||
|
||||
// API selector should now be visible, keywords textarea should be hidden
|
||||
expect(screen.getByTestId('api-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle empty lines in keywords', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/feature\.moderation\.modal\.keywords\.placeholder/) as HTMLTextAreaElement
|
||||
fireEvent.change(textarea, { target: { value: 'word1\n\nword2\n\n' } })
|
||||
|
||||
expect(textarea.value).toBe('word1\n\nword2\n')
|
||||
})
|
||||
|
||||
it('should show OpenAI not configured warning when OpenAI provider is not set up', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/feature\.moderation\.modal\.openaiNotConfig\.before/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open settings modal when provider link is clicked in OpenAI warning', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/settings\.provider/))
|
||||
|
||||
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not save when OpenAI type is selected but not configured', async () => {
|
||||
mockModelProvidersData = {
|
||||
data: {
|
||||
data: [{
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: 'free',
|
||||
quota_configurations: [],
|
||||
},
|
||||
custom_configuration: { status: 'no-configure' },
|
||||
}],
|
||||
},
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'openai_moderation', config: { inputs_config: { enabled: true, preset_response: 'blocked' }, outputs_config: { enabled: false, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render code-based extension providers', 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 render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Custom Extension')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show form generation when code-based extension is selected', 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 render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('API URL')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Enter URL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should initialize config from form schema when switching to code-based extension', 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: 'https://default.com', placeholder: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={defaultData}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Click on the custom extension provider
|
||||
fireEvent.click(screen.getByText('Custom Extension'))
|
||||
|
||||
// The form input should use the default value from form schema
|
||||
expect(screen.getByDisplayValue('https://default.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error when required form schema field is empty on save', 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: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'custom-ext', config: { inputs_config: { enabled: true, preset_response: 'blocked' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('should save with code-based extension config when valid', 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: '', options: [], max_length: 200 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
}
|
||||
|
||||
await render(
|
||||
<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()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.save/))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'custom-ext',
|
||||
config: expect.objectContaining({
|
||||
api_url: 'https://example.com',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should show doc link for api type', async () => {
|
||||
await render(
|
||||
<ModerationSettingModal
|
||||
data={{ ...defaultData, type: 'api', config: { inputs_config: { enabled: true, preset_response: '' } } }}
|
||||
onCancel={vi.fn()}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/apiBasedExtension\.link/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { CodeBasedExtensionItem } from '@/models/common'
|
||||
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import { InfoCircle } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
|
||||
@@ -238,8 +235,21 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
className="!mt-14 !w-[600px] !max-w-none !p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title-2xl-semi-bold text-text-primary">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onCancel}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary title-2xl-semi-bold">{t('feature.moderation.modal.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer p-1"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="text-sm font-medium leading-9 text-text-primary">
|
||||
@@ -251,9 +261,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div
|
||||
key={provider.key}
|
||||
className={cn(
|
||||
'system-sm-regular flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary',
|
||||
'flex h-8 cursor-default items-center rounded-md border border-components-option-card-option-border bg-components-option-card-option-bg px-2 text-text-secondary system-sm-regular',
|
||||
localeData.type !== provider.key && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
|
||||
localeData.type === provider.key && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs',
|
||||
localeData.type === provider.key && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs system-sm-medium',
|
||||
localeData.type === 'openai_moderation' && provider.key === 'openai_moderation' && !isOpenAIProviderConfigured && 'text-text-disabled',
|
||||
)}
|
||||
onClick={() => handleDataTypeChange(provider.key)}
|
||||
@@ -272,7 +282,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
{
|
||||
!isLoading && !isOpenAIProviderConfigured && localeData.type === 'openai_moderation' && (
|
||||
<div className="mt-2 flex items-center rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 py-2">
|
||||
<InfoCircle className="mr-1 h-4 w-4 text-[#F79009]" />
|
||||
<span className="i-custom-vender-line-general-info-circle mr-1 h-4 w-4 text-[#F79009]" />
|
||||
<div className="flex items-center text-xs font-medium text-gray-700">
|
||||
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
|
||||
<span
|
||||
@@ -324,7 +334,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
|
||||
>
|
||||
<BookOpen01 className="mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
|
||||
<span className="i-custom-vender-line-education-book-open-01 mr-1 h-3 w-3 text-text-tertiary group-hover:text-primary-600" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import MoreLikeThis from './more-like-this'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<MoreLikeThis disabled={props.disabled} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('MoreLikeThis', () => {
|
||||
it('should render the more-like-this feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
|
||||
it('should render tooltip for the feature', () => {
|
||||
renderWithProvider()
|
||||
|
||||
// MoreLikeThis has a tooltip prop, verifying the feature renders with title
|
||||
expect(screen.getByText(/feature\.moreLikeThis\.title/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { OnFeaturesChange } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { FeaturesProvider } from '../context'
|
||||
import SpeechToText from './speech-to-text'
|
||||
|
||||
const renderWithProvider = (props: { disabled?: boolean, onChange?: OnFeaturesChange } = {}) => {
|
||||
return render(
|
||||
<FeaturesProvider>
|
||||
<SpeechToText disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SpeechToText', () => {
|
||||
it('should render the speech-to-text feature card', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description text', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.speechToText\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(() => fireEvent.click(screen.getByRole('switch'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import TextToSpeech from './index'
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
languages: [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
],
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { disabled?: boolean, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<TextToSpeech disabled={props.disabled ?? false} onChange={props.onChange} />
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('TextToSpeech', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the text-to-speech title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when disabled', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/feature\.textToSpeech\.description/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a switch toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when toggled', () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show language and voice info when enabled and not hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('English')).toBeInTheDocument()
|
||||
expect(screen.getByText('alloy')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show default display text when voice is not set', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.defaultDisplay/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show voice settings button when hovering', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true },
|
||||
})
|
||||
|
||||
// Simulate mouse enter on the feature card
|
||||
const card = screen.getByText(/feature\.textToSpeech\.title/).closest('[class]')!
|
||||
fireEvent.mouseEnter(card)
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show autoPlay enabled text when autoPlay is enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', autoPlay: TtsAutoPlay.enabled },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlayEnabled/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show autoPlay disabled text when autoPlay is not enabled', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US' },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlayDisabled/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import type { Features } from '../../types'
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TtsAutoPlay } from '@/types/app'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import ParamConfigContent from './param-config-content'
|
||||
|
||||
let mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
|
||||
let mockPathname = '/app/test-app-id/configuration'
|
||||
|
||||
let mockVoiceItems: { value: string, name: string }[] | undefined = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
|
||||
const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({
|
||||
data: mockVoiceItems,
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => mockPathname,
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n-config/language', () => ({
|
||||
get languages() {
|
||||
return mockLanguages
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppVoices: (appId: string, language?: string) => mockUseAppVoices(appId, language),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (
|
||||
props: { onClose?: () => void, onChange?: OnFeaturesChange } = {},
|
||||
featureOverrides?: Partial<Features>,
|
||||
) => {
|
||||
const features = { ...defaultFeatures, ...featureOverrides }
|
||||
return render(
|
||||
<FeaturesProvider features={features}>
|
||||
<ParamConfigContent
|
||||
onClose={props.onClose ?? vi.fn()}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ParamConfigContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPathname = '/app/test-app-id/configuration'
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: 'Hello world' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '你好' },
|
||||
]
|
||||
mockVoiceItems = [
|
||||
{ value: 'alloy', name: 'Alloy' },
|
||||
{ value: 'echo', name: 'Echo' },
|
||||
]
|
||||
})
|
||||
|
||||
// Rendering states and static UI sections.
|
||||
describe('Rendering', () => {
|
||||
it('should render voice settings title', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render language label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.language/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render voice label', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.voice/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render autoPlay toggle', () => {
|
||||
renderWithProvider()
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.autoPlay/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tooltip icon for language', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const languageLabel = screen.getByText(/voice\.voiceSettings\.language/)
|
||||
expect(languageLabel).toBeInTheDocument()
|
||||
const tooltip = languageLabel.parentElement as HTMLElement
|
||||
expect(tooltip.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display language listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should display current voice in listbox button', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render audition button when language has example', () => {
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render audition button when language has no example', () => {
|
||||
mockLanguages = [
|
||||
{ value: 'en-US', name: 'English', example: '' },
|
||||
{ value: 'zh-Hans', name: '中文', example: '' },
|
||||
]
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
const auditionButton = screen.queryByTestId('audition-button')
|
||||
expect(auditionButton).toBeNull()
|
||||
})
|
||||
|
||||
it('should render with no language set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render with no voice set and use first as default', () => {
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'nonexistent', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User-triggered behavior and callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onClose when close button receives Enter key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButton)
|
||||
onClose.mockClear()
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onClose when close button receives unrelated key', async () => {
|
||||
const onClose = vi.fn()
|
||||
renderWithProvider({ onClose })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /close/i })
|
||||
closeButton.focus()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle autoPlay switch and call onChange', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set autoPlay to disabled when toggled off from enabled state', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider(
|
||||
{ onChange },
|
||||
{ text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.enabled } },
|
||||
)
|
||||
|
||||
const autoPlaySwitch = screen.getByRole('switch')
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(autoPlaySwitch)
|
||||
|
||||
expect(autoPlaySwitch).toHaveAttribute('aria-checked', 'false')
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call feature update without onChange callback', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
await userEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open language listbox and show options', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should handle language change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle voice change', async () => {
|
||||
const onChange = vi.fn()
|
||||
renderWithProvider({ onChange })
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThan(1)
|
||||
await userEvent.click(options[1])
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show selected language option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const languageButton = buttons.find(btn => btn.textContent?.includes('voice.language.'))
|
||||
expect(languageButton).toBeDefined()
|
||||
await userEvent.click(languageButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('voice.language.enUS'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('should show selected voice option in listbox', async () => {
|
||||
renderWithProvider()
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const voiceButton = buttons.find(btn => btn.textContent?.includes('Alloy'))
|
||||
expect(voiceButton).toBeDefined()
|
||||
await userEvent.click(voiceButton!)
|
||||
const options = await screen.findAllByRole('option')
|
||||
expect(options.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const selectedOption = options.find(opt => opt.textContent?.includes('Alloy'))
|
||||
expect(selectedOption).toBeDefined()
|
||||
expect(selectedOption).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Fallback and boundary scenarios.
|
||||
describe('Edge Cases', () => {
|
||||
it('should show placeholder and disable voice selection when no languages are available', () => {
|
||||
mockLanguages = []
|
||||
mockVoiceItems = undefined
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
const placeholderTexts = screen.getAllByText(/placeholder\.select/)
|
||||
expect(placeholderTexts.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const disabledButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true')
|
||||
|
||||
expect(disabledButtons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should call useAppVoices with empty appId when pathname has no app segment', () => {
|
||||
mockPathname = '/configuration'
|
||||
|
||||
renderWithProvider()
|
||||
|
||||
expect(mockUseAppVoices).toHaveBeenCalledWith('', 'en-US')
|
||||
})
|
||||
|
||||
it('should render language text when selected language value is empty string', () => {
|
||||
mockLanguages = [{ value: '' as string, name: 'Unknown Language', example: '' }]
|
||||
|
||||
renderWithProvider({}, {
|
||||
text2speech: { enabled: true, language: '', voice: '', autoPlay: TtsAutoPlay.disabled },
|
||||
})
|
||||
|
||||
expect(screen.getByText(/voice\.language\./)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,6 @@
|
||||
import type { OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
@@ -67,11 +65,25 @@ const VoiceParamConfig = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div>
|
||||
<div className="cursor-pointer p-1" onClick={onClose}><RiCloseLine className="h-4 w-4 text-text-tertiary" /></div>
|
||||
<div className="text-text-primary system-xl-semibold">{t('voice.voiceSettings.title', { ns: 'appDebug' })}</div>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('appDebug:voice.voiceSettings.close')}
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
|
||||
<div className="mb-1 flex items-center py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
@@ -103,10 +115,7 @@ const VoiceParamConfig = ({
|
||||
: localLanguagePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -137,7 +146,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -150,7 +159,7 @@ const VoiceParamConfig = ({
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -173,10 +182,7 @@ const VoiceParamConfig = ({
|
||||
{voiceItem?.name ?? localVoicePlaceholder}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="i-heroicons-chevron-down-20-solid h-4 w-4 text-text-tertiary" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
@@ -203,7 +209,7 @@ const VoiceParamConfig = ({
|
||||
<span
|
||||
className={cn('absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary')}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="i-heroicons-check-20-solid h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -215,7 +221,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</Listbox>
|
||||
{languageItem?.example && (
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1">
|
||||
<div className="h-8 shrink-0 rounded-lg bg-components-button-tertiary-bg p-1" data-testid="audition-button">
|
||||
<AudioBtn
|
||||
value={languageItem?.example}
|
||||
isAudition
|
||||
@@ -227,7 +233,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-1 py-1 text-text-secondary">
|
||||
<div className="mb-1 py-1 text-text-secondary system-sm-semibold">
|
||||
{t('voice.voiceSettings.autoPlay', { ns: 'appDebug' })}
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Features } from '../../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FeaturesProvider } from '../../context'
|
||||
import VoiceSettings from './voice-settings'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
usePathname: () => '/app/test-app-id/configuration',
|
||||
useParams: () => ({ appId: 'test-app-id' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useAppVoices: () => ({
|
||||
data: [{ name: 'alloy', value: 'alloy' }],
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultFeatures: Features = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
text2speech: { enabled: true, language: 'en-US', voice: 'alloy' },
|
||||
speech2text: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const renderWithProvider = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<FeaturesProvider features={defaultFeatures}>
|
||||
{ui}
|
||||
</FeaturesProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('VoiceSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render children in trigger', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={vi.fn()}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ParamConfigContent in portal', () => {
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={true} onOpen={vi.fn()}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onOpen with toggle function when trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={onOpen}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).toHaveBeenCalled()
|
||||
// The toggle function should flip the open state
|
||||
const toggleFn = onOpen.mock.calls[0][0]
|
||||
expect(typeof toggleFn).toBe('function')
|
||||
expect(toggleFn(false)).toBe(true)
|
||||
expect(toggleFn(true)).toBe(false)
|
||||
})
|
||||
|
||||
it('should not call onOpen when disabled and trigger is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={false} onOpen={onOpen} disabled>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onOpen with false when close is clicked', () => {
|
||||
const onOpen = vi.fn()
|
||||
renderWithProvider(
|
||||
<VoiceSettings open={true} onOpen={onOpen}>
|
||||
<button>Settings</button>
|
||||
</VoiceSettings>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /voice\.voiceSettings\.close/ }))
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
180
web/app/components/base/features/store.spec.ts
Normal file
180
web/app/components/base/features/store.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { createFeaturesStore } from './store'
|
||||
|
||||
describe('createFeaturesStore', () => {
|
||||
describe('Default State', () => {
|
||||
it('should create a store with moreLikeThis disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.moreLikeThis?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with opening disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.opening?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with suggested disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.suggested?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with text2speech disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.text2speech?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with speech2text disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.speech2text?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with citation disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.citation?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with moderation disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.moderation?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should create a store with annotationReply disabled by default', () => {
|
||||
const store = createFeaturesStore()
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.features.annotationReply?.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Image Initialization', () => {
|
||||
it('should initialize file image enabled as false', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize file image detail as high resolution', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.detail).toBe(Resolution.high)
|
||||
})
|
||||
|
||||
it('should initialize file image number_limits as 3', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.number_limits).toBe(3)
|
||||
})
|
||||
|
||||
it('should initialize file image transfer_methods with local and remote options', () => {
|
||||
const store = createFeaturesStore()
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.file?.image?.transfer_methods).toEqual([
|
||||
TransferMethod.local_file,
|
||||
TransferMethod.remote_url,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feature Merging', () => {
|
||||
it('should merge initial moreLikeThis enabled state', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
moreLikeThis: { enabled: true },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.moreLikeThis?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should merge initial opening enabled state', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
opening: { enabled: true },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.opening?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should preserve additional properties when merging', () => {
|
||||
const store = createFeaturesStore({
|
||||
features: {
|
||||
opening: { enabled: true, opening_statement: 'Hello!' },
|
||||
},
|
||||
})
|
||||
const { features } = store.getState()
|
||||
|
||||
expect(features.opening?.enabled).toBe(true)
|
||||
expect(features.opening?.opening_statement).toBe('Hello!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setFeatures', () => {
|
||||
it('should update moreLikeThis feature via setFeatures', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setFeatures({
|
||||
moreLikeThis: { enabled: true },
|
||||
})
|
||||
|
||||
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should update multiple features via setFeatures', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setFeatures({
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: { enabled: true },
|
||||
})
|
||||
|
||||
expect(store.getState().features.moreLikeThis?.enabled).toBe(true)
|
||||
expect(store.getState().features.opening?.enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('showFeaturesModal', () => {
|
||||
it('should initialize showFeaturesModal as false', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle showFeaturesModal to true', () => {
|
||||
const store = createFeaturesStore()
|
||||
|
||||
store.getState().setShowFeaturesModal(true)
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle showFeaturesModal to false', () => {
|
||||
const store = createFeaturesStore()
|
||||
store.getState().setShowFeaturesModal(true)
|
||||
|
||||
store.getState().setShowFeaturesModal(false)
|
||||
|
||||
expect(store.getState().showFeaturesModal).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1862,17 +1862,6 @@
|
||||
"app/components/base/features/new-feature-panel/conversation-opener/modal.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/dialog-wrapper.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/feature-bar.tsx": {
|
||||
@@ -1893,11 +1882,6 @@
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/file-upload/setting-content.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -1922,9 +1906,6 @@
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
@@ -1934,11 +1915,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
Reference in New Issue
Block a user