-
+
{t('feature.moderation.modal.openaiNotConfig.before', { ns: 'appDebug' })}
= ({
rel="noopener noreferrer"
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"
>
-
+
{t('apiBasedExtension.link', { ns: 'common' })}
diff --git a/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx b/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx
new file mode 100644
index 0000000000..592e08a995
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/more-like-this.spec.tsx
@@ -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(
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx b/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx
new file mode 100644
index 0000000000..341065fe21
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/speech-to-text.spec.tsx
@@ -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(
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx
new file mode 100644
index 0000000000..a9623a8215
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/index.spec.tsx
@@ -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
,
+) => {
+ const features = { ...defaultFeatures, ...featureOverrides }
+ return render(
+
+
+ ,
+ )
+}
+
+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()
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx
new file mode 100644
index 0000000000..b4a0dafd91
--- /dev/null
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.spec.tsx
@@ -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,
+) => {
+ const features = { ...defaultFeatures, ...featureOverrides }
+ return render(
+
+
+ ,
+ )
+}
+
+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()
+ })
+ })
+})
diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
index 21b4f1e0cd..631691c42f 100644
--- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
+++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
@@ -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 (
<>
-
{t('voice.voiceSettings.title', { ns: 'appDebug' })}
-
+
{t('voice.voiceSettings.title', { ns: 'appDebug' })}
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ onClose()
+ }
+ }}
+ >
+
+
-
+
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
-
+
-
+
)}
>
@@ -150,7 +159,7 @@ const VoiceParamConfig = ({
-
+
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
@@ -173,10 +182,7 @@ const VoiceParamConfig = ({
{voiceItem?.name ?? localVoicePlaceholder}
-
+
-
+
)}
>
@@ -215,7 +221,7 @@ const VoiceParamConfig = ({
{languageItem?.example && (
-
+
-
+
{t('voice.voiceSettings.autoPlay', { ns: 'appDebug' })}
({
+ 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(
+
+ {ui}
+ ,
+ )
+}
+
+describe('VoiceSettings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should render children in trigger', () => {
+ renderWithProvider(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Settings')).toBeInTheDocument()
+ })
+
+ it('should render ParamConfigContent in portal', () => {
+ renderWithProvider(
+
+
+ ,
+ )
+
+ expect(screen.getByText(/voice\.voiceSettings\.title/)).toBeInTheDocument()
+ })
+
+ it('should call onOpen with toggle function when trigger is clicked', () => {
+ const onOpen = vi.fn()
+ renderWithProvider(
+
+
+ ,
+ )
+
+ 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(
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getByText('Settings'))
+
+ expect(onOpen).not.toHaveBeenCalled()
+ })
+
+ it('should call onOpen with false when close is clicked', () => {
+ const onOpen = vi.fn()
+ renderWithProvider(
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: /voice\.voiceSettings\.close/ }))
+
+ expect(onOpen).toHaveBeenCalledWith(false)
+ })
+})
diff --git a/web/app/components/base/features/store.spec.ts b/web/app/components/base/features/store.spec.ts
new file mode 100644
index 0000000000..fc2cf8822e
--- /dev/null
+++ b/web/app/components/base/features/store.spec.ts
@@ -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)
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 4bc0ce7e99..9f0104333f 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -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