test: add unit tests for plugin detail panel components including model selector, TTS parameters, and subscription management

This commit is contained in:
CodingOnStar 2025-12-29 13:16:20 +08:00
parent 0b1439fee4
commit 5a90b027ac
8 changed files with 9954 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,717 @@
import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
import LLMParamsPanel from './llm-params-panel'
// ==================== Mock Setup ====================
// All vi.mock() calls are hoisted, so inline all mock data
// Mock useModelParameterRules hook
const mockUseModelParameterRules = vi.fn()
vi.mock('@/service/use-common', () => ({
useModelParameterRules: (provider: string, modelId: string) => mockUseModelParameterRules(provider, modelId),
}))
// Mock config constants with inline data
vi.mock('@/config', () => ({
TONE_LIST: [
{
id: 1,
name: 'Creative',
config: {
temperature: 0.8,
top_p: 0.9,
presence_penalty: 0.1,
frequency_penalty: 0.1,
},
},
{
id: 2,
name: 'Balanced',
config: {
temperature: 0.5,
top_p: 0.85,
presence_penalty: 0.2,
frequency_penalty: 0.3,
},
},
{
id: 3,
name: 'Precise',
config: {
temperature: 0.2,
top_p: 0.75,
presence_penalty: 0.5,
frequency_penalty: 0.5,
},
},
{
id: 4,
name: 'Custom',
},
],
STOP_PARAMETER_RULE: {
default: [],
help: {
en_US: 'Stop sequences help text',
zh_Hans: '停止序列帮助文本',
},
label: {
en_US: 'Stop sequences',
zh_Hans: '停止序列',
},
name: 'stop',
required: false,
type: 'tag',
tagPlaceholder: {
en_US: 'Enter sequence and press Tab',
zh_Hans: '输入序列并按 Tab 键',
},
},
PROVIDER_WITH_PRESET_TONE: ['langgenius/openai/openai', 'langgenius/azure_openai/azure_openai'],
}))
// Mock PresetsParameter component
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (toneId: number) => void }) => (
<div data-testid="presets-parameter">
<button data-testid="preset-creative" onClick={() => onSelect(1)}>Creative</button>
<button data-testid="preset-balanced" onClick={() => onSelect(2)}>Balanced</button>
<button data-testid="preset-precise" onClick={() => onSelect(3)}>Precise</button>
<button data-testid="preset-custom" onClick={() => onSelect(4)}>Custom</button>
</div>
),
}))
// Mock ParameterItem component
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal/parameter-item', () => ({
default: ({ parameterRule, value, onChange, onSwitch, isInWorkflow }: {
parameterRule: { name: string, label: { en_US: string }, default?: unknown }
value: unknown
onChange: (v: unknown) => void
onSwitch: (checked: boolean, assignValue: unknown) => void
isInWorkflow?: boolean
}) => (
<div
data-testid={`parameter-item-${parameterRule.name}`}
data-value={JSON.stringify(value)}
data-is-in-workflow={isInWorkflow}
>
<span>{parameterRule.label.en_US}</span>
<button data-testid={`change-${parameterRule.name}`} onClick={() => onChange(0.5)}>Change</button>
<button data-testid={`switch-on-${parameterRule.name}`} onClick={() => onSwitch(true, parameterRule.default)}>Switch On</button>
<button data-testid={`switch-off-${parameterRule.name}`} onClick={() => onSwitch(false, parameterRule.default)}>Switch Off</button>
</div>
),
}))
// ==================== Test Utilities ====================
/**
* Factory function to create a ModelParameterRule with defaults
*/
const createParameterRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temperature',
label: { en_US: 'Temperature', zh_Hans: '温度' },
type: 'float',
default: 0.7,
min: 0,
max: 2,
precision: 2,
required: false,
...overrides,
})
/**
* Factory function to create default props
*/
const createDefaultProps = (overrides: Partial<{
isAdvancedMode: boolean
provider: string
modelId: string
completionParams: FormValue
onCompletionParamsChange: (newParams: FormValue) => void
}> = {}) => ({
isAdvancedMode: false,
provider: 'langgenius/openai/openai',
modelId: 'gpt-4',
completionParams: {},
onCompletionParamsChange: vi.fn(),
...overrides,
})
/**
* Setup mock for useModelParameterRules
*/
const setupModelParameterRulesMock = (config: {
data?: ModelParameterRule[]
isPending?: boolean
} = {}) => {
mockUseModelParameterRules.mockReturnValue({
data: config.data ? { data: config.data } : undefined,
isPending: config.isPending ?? false,
})
}
// ==================== Tests ====================
describe('LLMParamsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
setupModelParameterRulesMock({ data: [], isPending: false })
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<LLMParamsPanel {...props} />)
// Assert
expect(container).toBeInTheDocument()
})
it('should render loading state when isPending is true', () => {
// Arrange
setupModelParameterRulesMock({ isPending: true })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert - Loading component uses aria-label instead of visible text
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render parameters header', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByText('common.modelProvider.parameters')).toBeInTheDocument()
})
it('should render PresetsParameter for openai provider', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({ provider: 'langgenius/openai/openai' })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
})
it('should render PresetsParameter for azure_openai provider', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({ provider: 'langgenius/azure_openai/azure_openai' })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('presets-parameter')).toBeInTheDocument()
})
it('should not render PresetsParameter for non-preset providers', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({ provider: 'anthropic/claude' })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.queryByTestId('presets-parameter')).not.toBeInTheDocument()
})
it('should render parameter items when rules are available', () => {
// Arrange
const rules = [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p', label: { en_US: 'Top P', zh_Hans: 'Top P' } }),
]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
})
it('should not render parameter items when rules are empty', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.queryByTestId('parameter-item-temperature')).not.toBeInTheDocument()
})
it('should include stop parameter rule in advanced mode', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({ isAdvancedMode: true })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
})
it('should not include stop parameter rule in non-advanced mode', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({ isAdvancedMode: false })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
})
it('should pass isInWorkflow=true to ParameterItem', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-is-in-workflow', 'true')
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should call useModelParameterRules with provider and modelId', () => {
// Arrange
const props = createDefaultProps({
provider: 'test-provider',
modelId: 'test-model',
})
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(mockUseModelParameterRules).toHaveBeenCalledWith('test-provider', 'test-model')
})
it('should pass completion params value to ParameterItem', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
completionParams: { temperature: 0.8 },
})
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toHaveAttribute('data-value', '0.8')
})
it('should handle undefined completion params value', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
completionParams: {},
})
// Act
render(<LLMParamsPanel {...props} />)
// Assert - when value is undefined, JSON.stringify returns undefined string
expect(screen.getByTestId('parameter-item-temperature')).not.toHaveAttribute('data-value')
})
})
// ==================== Event Handlers ====================
describe('Event Handlers', () => {
describe('handleSelectPresetParameter', () => {
it('should apply Creative preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
completionParams: { existing: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('preset-creative'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
existing: 'value',
temperature: 0.8,
top_p: 0.9,
presence_penalty: 0.1,
frequency_penalty: 0.1,
})
})
it('should apply Balanced preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
completionParams: {},
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('preset-balanced'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
temperature: 0.5,
top_p: 0.85,
presence_penalty: 0.2,
frequency_penalty: 0.3,
})
})
it('should apply Precise preset config', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
completionParams: {},
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('preset-precise'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
temperature: 0.2,
top_p: 0.75,
presence_penalty: 0.5,
frequency_penalty: 0.5,
})
})
it('should apply empty config for Custom preset (spreads undefined)', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps({
provider: 'langgenius/openai/openai',
onCompletionParamsChange,
completionParams: { existing: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('preset-custom'))
// Assert - Custom preset has no config, so only existing params are kept
expect(onCompletionParamsChange).toHaveBeenCalledWith({ existing: 'value' })
})
})
describe('handleParamChange', () => {
it('should call onCompletionParamsChange with updated param', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
onCompletionParamsChange,
completionParams: { existing: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('change-temperature'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
existing: 'value',
temperature: 0.5,
})
})
it('should override existing param value', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
onCompletionParamsChange,
completionParams: { temperature: 0.9 },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('change-temperature'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
temperature: 0.5,
})
})
})
describe('handleSwitch', () => {
it('should add param when switch is turned on', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
const rules = [createParameterRule({ name: 'temperature', default: 0.7 })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
onCompletionParamsChange,
completionParams: { existing: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('switch-on-temperature'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
existing: 'value',
temperature: 0.7,
})
})
it('should remove param when switch is turned off', () => {
// Arrange
const onCompletionParamsChange = vi.fn()
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({
onCompletionParamsChange,
completionParams: { temperature: 0.8, other: 'value' },
})
// Act
render(<LLMParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('switch-off-temperature'))
// Assert
expect(onCompletionParamsChange).toHaveBeenCalledWith({
other: 'value',
})
})
})
})
// ==================== Memoization ====================
describe('Memoization - parameterRules', () => {
it('should return empty array when data is undefined', () => {
// Arrange
mockUseModelParameterRules.mockReturnValue({
data: undefined,
isPending: false,
})
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert - no parameter items should be rendered
expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
})
it('should return empty array when data.data is undefined', () => {
// Arrange
mockUseModelParameterRules.mockReturnValue({
data: { data: undefined },
isPending: false,
})
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.queryByTestId(/parameter-item-/)).not.toBeInTheDocument()
})
it('should use data.data when available', () => {
// Arrange
const rules = [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty completionParams', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({ completionParams: {} })
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
})
it('should handle multiple parameter rules', () => {
// Arrange
const rules = [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
createParameterRule({ name: 'max_tokens', type: 'int' }),
createParameterRule({ name: 'presence_penalty' }),
]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps()
// Act
render(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-max_tokens')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-presence_penalty')).toBeInTheDocument()
})
it('should use unique keys for parameter items based on modelId and name', () => {
// Arrange
const rules = [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({ modelId: 'gpt-4' })
// Act
const { container } = render(<LLMParamsPanel {...props} />)
// Assert - verify both items are rendered (keys are internal but rendering proves uniqueness)
const items = container.querySelectorAll('[data-testid^="parameter-item-"]')
expect(items).toHaveLength(2)
})
})
// ==================== Re-render Behavior ====================
describe('Re-render Behavior', () => {
it('should update parameter items when rules change', () => {
// Arrange
const initialRules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: initialRules, isPending: false })
const props = createDefaultProps()
// Act
const { rerender } = render(<LLMParamsPanel {...props} />)
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.queryByTestId('parameter-item-top_p')).not.toBeInTheDocument()
// Update mock
const newRules = [
createParameterRule({ name: 'temperature' }),
createParameterRule({ name: 'top_p' }),
]
setupModelParameterRulesMock({ data: newRules, isPending: false })
rerender(<LLMParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
expect(screen.getByTestId('parameter-item-top_p')).toBeInTheDocument()
})
it('should show loading when transitioning from loaded to loading', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps()
// Act
const { rerender } = render(<LLMParamsPanel {...props} />)
expect(screen.getByTestId('parameter-item-temperature')).toBeInTheDocument()
// Update to loading
setupModelParameterRulesMock({ isPending: true })
rerender(<LLMParamsPanel {...props} />)
// Assert - Loading component uses role="status" with aria-label
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should update when isAdvancedMode changes', () => {
// Arrange
const rules = [createParameterRule({ name: 'temperature' })]
setupModelParameterRulesMock({ data: rules, isPending: false })
const props = createDefaultProps({ isAdvancedMode: false })
// Act
const { rerender } = render(<LLMParamsPanel {...props} />)
expect(screen.queryByTestId('parameter-item-stop')).not.toBeInTheDocument()
rerender(<LLMParamsPanel {...props} isAdvancedMode={true} />)
// Assert
expect(screen.getByTestId('parameter-item-stop')).toBeInTheDocument()
})
})
// ==================== Component Type ====================
describe('Component Type', () => {
it('should be a functional component', () => {
// Assert
expect(typeof LLMParamsPanel).toBe('function')
})
it('should accept all required props', () => {
// Arrange
setupModelParameterRulesMock({ data: [], isPending: false })
const props = createDefaultProps()
// Act & Assert
expect(() => render(<LLMParamsPanel {...props} />)).not.toThrow()
})
})
})

View File

@ -0,0 +1,623 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
import TTSParamsPanel from './tts-params-panel'
// ==================== Mock Setup ====================
// All vi.mock() calls are hoisted, so inline all mock data
// Mock languages data with inline definition
vi.mock('@/i18n-config/language', () => ({
languages: [
{ value: 'en-US', name: 'English (United States)', supported: true },
{ value: 'zh-Hans', name: '简体中文', supported: true },
{ value: 'ja-JP', name: '日本語', supported: true },
{ value: 'unsupported-lang', name: 'Unsupported Language', supported: false },
],
}))
// Mock PortalSelect component
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
value,
items,
onSelect,
triggerClassName,
popupClassName,
popupInnerClassName,
}: {
value: string
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
triggerClassName?: string
popupClassName?: string
popupInnerClassName?: string
}) => (
<div
data-testid="portal-select"
data-value={value}
data-trigger-class={triggerClassName}
data-popup-class={popupClassName}
data-popup-inner-class={popupInnerClassName}
>
<span data-testid="selected-value">{value}</span>
<div data-testid="items-container">
{items.map(item => (
<button
key={item.value}
data-testid={`select-item-${item.value}`}
onClick={() => onSelect({ value: item.value })}
>
{item.name}
</button>
))}
</div>
</div>
),
}))
// ==================== Test Utilities ====================
/**
* Factory function to create a voice item
*/
const createVoiceItem = (overrides: Partial<{ mode: string, name: string }> = {}) => ({
mode: 'alloy',
name: 'Alloy',
...overrides,
})
/**
* Factory function to create a currentModel with voices
*/
const createCurrentModel = (voices: Array<{ mode: string, name: string }> = []) => ({
model_properties: {
voices,
},
})
/**
* Factory function to create default props
*/
const createDefaultProps = (overrides: Partial<{
currentModel: { model_properties: { voices: Array<{ mode: string, name: string }> } } | null
language: string
voice: string
onChange: (language: string, voice: string) => void
}> = {}) => ({
currentModel: createCurrentModel([
createVoiceItem({ mode: 'alloy', name: 'Alloy' }),
createVoiceItem({ mode: 'echo', name: 'Echo' }),
createVoiceItem({ mode: 'fable', name: 'Fable' }),
]),
language: 'en-US',
voice: 'alloy',
onChange: vi.fn(),
...overrides,
})
// ==================== Tests ====================
describe('TTSParamsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==================== Rendering Tests ====================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<TTSParamsPanel {...props} />)
// Assert
expect(container).toBeInTheDocument()
})
it('should render language label', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByText('appDebug.voice.voiceSettings.language')).toBeInTheDocument()
})
it('should render voice label', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
})
it('should render two PortalSelect components', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects).toHaveLength(2)
})
it('should render language select with correct value', () => {
// Arrange
const props = createDefaultProps({ language: 'zh-Hans' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
})
it('should render voice select with correct value', () => {
// Arrange
const props = createDefaultProps({ voice: 'echo' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'echo')
})
it('should only show supported languages in language select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-en-US')).toBeInTheDocument()
expect(screen.getByTestId('select-item-zh-Hans')).toBeInTheDocument()
expect(screen.getByTestId('select-item-ja-JP')).toBeInTheDocument()
expect(screen.queryByTestId('select-item-unsupported-lang')).not.toBeInTheDocument()
})
it('should render voice items from currentModel', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
expect(screen.getByTestId('select-item-echo')).toBeInTheDocument()
expect(screen.getByTestId('select-item-fable')).toBeInTheDocument()
})
})
// ==================== Props Testing ====================
describe('Props', () => {
it('should apply trigger className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
})
it('should apply popup className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
})
it('should apply popup inner className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
})
})
// ==================== Event Handlers ====================
describe('Event Handlers', () => {
describe('setLanguage', () => {
it('should call onChange with new language and current voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'alloy')
})
it('should call onChange with different languages', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'echo',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-ja-JP'))
// Assert
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
})
it('should preserve voice when changing language', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'fable',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-zh-Hans'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
})
})
describe('setVoice', () => {
it('should call onChange with current language and new voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'en-US',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledWith('en-US', 'echo')
})
it('should call onChange with different voices', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'zh-Hans',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-fable'))
// Assert
expect(onChange).toHaveBeenCalledWith('zh-Hans', 'fable')
})
it('should preserve language when changing voice', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({
onChange,
language: 'ja-JP',
voice: 'alloy',
})
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledWith('ja-JP', 'echo')
})
})
})
// ==================== Memoization ====================
describe('Memoization - voiceList', () => {
it('should return empty array when currentModel is null', () => {
// Arrange
const props = createDefaultProps({ currentModel: null })
// Act
render(<TTSParamsPanel {...props} />)
// Assert - no voice items should be rendered
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
expect(screen.queryByTestId('select-item-echo')).not.toBeInTheDocument()
})
it('should return empty array when currentModel is undefined', () => {
// Arrange
const props = {
currentModel: undefined,
language: 'en-US',
voice: 'alloy',
onChange: vi.fn(),
}
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should map voices with mode as value', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([
{ mode: 'voice-1', name: 'Voice One' },
{ mode: 'voice-2', name: 'Voice Two' },
]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-voice-1')).toBeInTheDocument()
expect(screen.getByTestId('select-item-voice-2')).toBeInTheDocument()
})
it('should handle currentModel with empty voices array', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert - no voice items (except language items)
const voiceSelects = screen.getAllByTestId('portal-select')
// Second select is voice select, should have no voice items in items-container
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
expect(voiceItemsContainer?.children).toHaveLength(0)
})
it('should handle currentModel with single voice', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([
{ mode: 'single-voice', name: 'Single Voice' },
]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-single-voice')).toBeInTheDocument()
})
})
// ==================== Edge Cases ====================
describe('Edge Cases', () => {
it('should handle empty language value', () => {
// Arrange
const props = createDefaultProps({ language: '' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', '')
})
it('should handle empty voice value', () => {
// Arrange
const props = createDefaultProps({ voice: '' })
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', '')
})
it('should handle many voices', () => {
// Arrange
const manyVoices = Array.from({ length: 20 }, (_, i) => ({
mode: `voice-${i}`,
name: `Voice ${i}`,
}))
const props = createDefaultProps({
currentModel: createCurrentModel(manyVoices),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-voice-0')).toBeInTheDocument()
expect(screen.getByTestId('select-item-voice-19')).toBeInTheDocument()
})
it('should handle voice with special characters in mode', () => {
// Arrange
const props = createDefaultProps({
currentModel: createCurrentModel([
{ mode: 'voice-with_special.chars', name: 'Special Voice' },
]),
})
// Act
render(<TTSParamsPanel {...props} />)
// Assert
expect(screen.getByTestId('select-item-voice-with_special.chars')).toBeInTheDocument()
})
it('should handle onChange not being called multiple times', () => {
// Arrange
const onChange = vi.fn()
const props = createDefaultProps({ onChange })
// Act
render(<TTSParamsPanel {...props} />)
fireEvent.click(screen.getByTestId('select-item-echo'))
// Assert
expect(onChange).toHaveBeenCalledTimes(1)
})
})
// ==================== Re-render Behavior ====================
describe('Re-render Behavior', () => {
it('should update when language prop changes', () => {
// Arrange
const props = createDefaultProps({ language: 'en-US' })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
})
it('should update when voice prop changes', () => {
// Arrange
const props = createDefaultProps({ voice: 'alloy' })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
rerender(<TTSParamsPanel {...props} voice="echo" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
})
it('should update voice list when currentModel changes', () => {
// Arrange
const initialModel = createCurrentModel([
{ mode: 'alloy', name: 'Alloy' },
])
const props = createDefaultProps({ currentModel: initialModel })
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
expect(screen.queryByTestId('select-item-nova')).not.toBeInTheDocument()
const newModel = createCurrentModel([
{ mode: 'alloy', name: 'Alloy' },
{ mode: 'nova', name: 'Nova' },
])
rerender(<TTSParamsPanel {...props} currentModel={newModel} />)
// Assert
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
expect(screen.getByTestId('select-item-nova')).toBeInTheDocument()
})
it('should handle currentModel becoming null', () => {
// Arrange
const props = createDefaultProps()
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
expect(screen.getByTestId('select-item-alloy')).toBeInTheDocument()
rerender(<TTSParamsPanel {...props} currentModel={null} />)
// Assert
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
})
// ==================== Component Type ====================
describe('Component Type', () => {
it('should be a functional component', () => {
// Assert
expect(typeof TTSParamsPanel).toBe('function')
})
it('should accept all required props', () => {
// Arrange
const props = createDefaultProps()
// Act & Assert
expect(() => render(<TTSParamsPanel {...props} />)).not.toThrow()
})
})
// ==================== Accessibility ====================
describe('Accessibility', () => {
it('should have proper label structure for language select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const languageLabel = screen.getByText('appDebug.voice.voiceSettings.language')
expect(languageLabel).toHaveClass('system-sm-semibold')
})
it('should have proper label structure for voice select', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const voiceLabel = screen.getByText('appDebug.voice.voiceSettings.voice')
expect(voiceLabel).toHaveClass('system-sm-semibold')
})
})
})