test: add tests for base > features (#32397)

Co-authored-by: sahil <sahil@infocusp.com>
This commit is contained in:
Saumya Talwani
2026-02-24 10:31:45 +05:30
committed by GitHub
parent a0ddaed6d3
commit f923901d3f
38 changed files with 6028 additions and 65 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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