test: add unit tests for base-components part-3 (#32408)

This commit is contained in:
Poojan
2026-02-24 09:51:02 +05:30
committed by GitHub
parent 80f49367eb
commit 6e531fe44f
23 changed files with 1367 additions and 67 deletions

View File

@@ -0,0 +1,114 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TabHeader from './index'
describe('TabHeader Component', () => {
const mockItems = [
{ id: 'tab1', name: 'General' },
{ id: 'tab2', name: 'Settings' },
{ id: 'tab3', name: 'Profile', isRight: true },
{ id: 'tab4', name: 'Disabled Tab', disabled: true },
]
it('should render all items with correct names', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Profile')).toBeInTheDocument()
expect(screen.getByText('Disabled Tab')).toBeInTheDocument()
})
it('should separate items into left and right containers correctly', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
const leftContainer = screen.getByTestId('tab-header-left')
const rightContainer = screen.getByTestId('tab-header-right')
// Verify children count
expect(leftContainer.children.length).toBe(3)
expect(rightContainer.children.length).toBe(1)
// Verify specific item placement using within and toContainElement
const profileTab = screen.getByTestId('tab-header-item-tab3')
expect(rightContainer).toContainElement(profileTab)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(leftContainer).toContainElement(disabledTab)
})
it('should apply active styles to the selected tab', () => {
const activeClass = 'custom-active-style'
render(
<TabHeader
items={mockItems}
value="tab2"
activeItemClassName={activeClass}
onChange={() => { }}
/>,
)
const activeTab = screen.getByTestId('tab-header-item-tab2')
expect(activeTab).toHaveClass('border-components-tab-active')
expect(activeTab).toHaveClass(activeClass)
const inactiveTab = screen.getByTestId('tab-header-item-tab1')
expect(inactiveTab).toHaveClass('text-text-tertiary')
})
it('should call onChange when a non-disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
await user.click(screen.getByText('Settings'))
expect(handleChange).toHaveBeenCalledWith('tab2')
})
it('should not call onChange when a disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(disabledTab).toHaveClass('cursor-not-allowed')
await user.click(disabledTab)
expect(handleChange).not.toHaveBeenCalled()
})
it('should render icon and extra content when provided', () => {
const itemsWithExtras = [
{
id: 'extra',
name: 'Extra',
icon: <span data-testid="tab-icon">🚀</span>,
extra: <span data-testid="tab-extra">New</span>,
},
]
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
})
it('should apply custom class names for items and wrappers', () => {
render(
<TabHeader
items={mockItems}
value="tab1"
itemClassName="my-text-class"
itemWrapClassName="my-wrap-class"
onChange={() => { }}
/>,
)
const tabWrap = screen.getByTestId('tab-header-item-tab1')
// We target the inner div for the name class check
const tabText = within(tabWrap).getByText('General')
expect(tabWrap).toHaveClass('my-wrap-class')
expect(tabText).toHaveClass('my-text-class')
})
})

View File

@@ -32,8 +32,9 @@ const TabHeader: FC<ITabHeaderProps> = ({
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
<div
key={id}
data-testid={`tab-header-item-${id}`}
className={cn(
'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5',
'relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5 system-md-semibold',
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
@@ -46,11 +47,11 @@ const TabHeader: FC<ITabHeaderProps> = ({
</div>
)
return (
<div className="flex justify-between">
<div className="flex space-x-4">
<div data-testid="tab-header" className="flex justify-between">
<div data-testid="tab-header-left" className="flex space-x-4">
{items.filter(item => !item.isRight).map(renderItem)}
</div>
<div className="flex space-x-4">
<div data-testid="tab-header-right" className="flex space-x-4">
{items.filter(item => item.isRight).map(renderItem)}
</div>
</div>

View File

@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TabSliderNew from './index'
describe('TabSliderNew Component', () => {
const mockOptions = [
{ value: 'all', text: 'All' },
{ value: 'active', text: 'Active' },
{ value: 'inactive', text: 'Inactive', icon: <span data-testid="tab-icon">ico</span> },
]
it('should render all options with text and icons', () => {
render(
<TabSliderNew
value="all"
options={mockOptions}
onChange={() => { }}
/>,
)
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Active')).toBeInTheDocument()
expect(screen.getByText('Inactive')).toBeInTheDocument()
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
})
it('should apply active classes when the value matches the option', () => {
render(
<TabSliderNew
value="active"
options={mockOptions}
onChange={() => { }}
/>,
)
const activeTab = screen.getByTestId('tab-item-active')
const inactiveTab = screen.getByTestId('tab-item-all')
// Check active styles
expect(activeTab).toHaveClass('border-components-main-nav-nav-button-border')
expect(activeTab).toHaveClass('text-components-main-nav-nav-button-text-active')
// Check inactive styles
expect(inactiveTab).toHaveClass('text-text-tertiary')
expect(inactiveTab).not.toHaveClass('border-components-main-nav-nav-button-border')
})
it('should call onChange with the correct value when a tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(
<TabSliderNew
value="all"
options={mockOptions}
onChange={handleChange}
/>,
)
const inactiveTab = screen.getByTestId('tab-item-inactive')
await user.click(inactiveTab)
expect(handleChange).toHaveBeenCalledWith('inactive')
expect(handleChange).toHaveBeenCalledTimes(1)
})
it('should apply custom container className', () => {
const customClass = 'custom-container-style'
render(
<TabSliderNew
value="all"
options={mockOptions}
onChange={() => { }}
className={customClass}
/>,
)
expect(screen.getByTestId('tab-slider-new')).toHaveClass(customClass)
})
it('should call onChange even if clicking an already active tab', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(
<TabSliderNew
value="all"
options={mockOptions}
onChange={handleChange}
/>,
)
const activeTab = screen.getByTestId('tab-item-all')
await user.click(activeTab)
expect(handleChange).toHaveBeenCalledWith('all')
})
})

View File

@@ -19,10 +19,14 @@ const TabSliderNew: FC<TabSliderProps> = ({
options,
}) => {
return (
<div className={cn(className, 'relative flex')}>
<div
data-testid="tab-slider-new"
className={cn(className, 'relative flex')}
>
{options.map(option => (
<div
key={option.value}
data-testid={`tab-item-${option.value}`}
onClick={() => onChange(option.value)}
className={cn(
'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover',

View File

@@ -0,0 +1,100 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TabSlider from './index'
describe('TabSlider Component', () => {
const mockOptions = [
{ value: 'tab1', text: 'Overview' },
{ value: 'tab2', text: 'Settings' },
{ value: 'tab3', text: <span data-testid="custom-jsx">Advanced</span> },
]
it('should render all options correctly', () => {
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />)
expect(screen.getByText('Overview')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByTestId('custom-jsx')).toBeInTheDocument()
})
it('should call onChange when an inactive tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabSlider value="tab1" options={mockOptions} onChange={handleChange} />)
const settingsTab = screen.getByTestId('tab-slider-item-tab2')
await user.click(settingsTab)
expect(handleChange).toHaveBeenCalledWith('tab2')
})
it('should not call onChange when the active tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabSlider value="tab1" options={mockOptions} onChange={handleChange} />)
const activeTab = screen.getByTestId('tab-slider-item-tab1')
await user.click(activeTab)
expect(handleChange).not.toHaveBeenCalled()
})
it('should apply active styles and render indicator for the active tab', () => {
render(<TabSlider value="tab2" options={mockOptions} onChange={() => { }} />)
const activeTab = screen.getByTestId('tab-slider-item-tab2')
const activeText = within(activeTab).getByTestId('tab-slider-item-text')
const indicator = within(activeTab).getByTestId('tab-active-indicator')
expect(activeText).toHaveClass('text-text-primary')
expect(indicator).toBeInTheDocument()
const inactiveTab = screen.getByTestId('tab-slider-item-tab1')
const inactiveText = within(inactiveTab).getByTestId('tab-slider-item-text')
expect(inactiveText).toHaveClass('text-text-tertiary')
expect(within(inactiveTab).queryByTestId('tab-active-indicator')).not.toBeInTheDocument()
})
it('should apply smallItem styles when smallItem prop is true', () => {
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} smallItem />)
const item = screen.getByTestId('tab-slider-item-tab1')
expect(item).toHaveClass('system-sm-semibold-uppercase')
expect(item).not.toHaveClass('system-xl-semibold')
})
it('should apply standard sizing when smallItem prop is false', () => {
render(<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />)
const item = screen.getByTestId('tab-slider-item-tab1')
expect(item).toHaveClass('system-xl-semibold')
})
it('should handle border styles based on noBorderBottom prop', () => {
const { rerender } = render(
<TabSlider value="tab1" options={mockOptions} onChange={() => { }} />,
)
expect(screen.getByTestId('tab-slider')).toHaveClass('border-b')
rerender(
<TabSlider value="tab1" options={mockOptions} onChange={() => { }} noBorderBottom />,
)
expect(screen.getByTestId('tab-slider')).not.toHaveClass('border-b')
})
it('should apply custom itemClassName to all items', () => {
const customClass = 'my-custom-item'
render(
<TabSlider
value="tab1"
options={mockOptions}
onChange={() => { }}
itemClassName={customClass}
/>,
)
expect(screen.getByTestId('tab-slider-item-tab1')).toHaveClass(customClass)
expect(screen.getByTestId('tab-slider-item-tab2')).toHaveClass(customClass)
})
})

View File

@@ -25,17 +25,27 @@ const Item: FC<ItemProps> = ({
return (
<div
key={option.value}
data-testid={`tab-slider-item-${option.value}`}
className={cn(
'relative pb-2.5 ',
'relative pb-2.5',
!isActive && 'cursor-pointer',
smallItem ? 'system-sm-semibold-uppercase' : 'system-xl-semibold',
className,
)}
onClick={() => !isActive && onClick(option.value)}
>
<div className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}>{option.text}</div>
<div
data-testid="tab-slider-item-text"
className={cn(isActive ? 'text-text-primary' : 'text-text-tertiary')}
>
{option.text}
</div>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"></div>
<div
data-testid="tab-active-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-util-colors-blue-brand-blue-brand-600"
>
</div>
)}
</div>
)
@@ -61,7 +71,10 @@ const TabSlider: FC<Props> = ({
smallItem,
}) => {
return (
<div className={cn(className, !noBorderBottom && 'border-b border-divider-subtle', 'flex space-x-6')}>
<div
data-testid="tab-slider"
className={cn(className, !noBorderBottom && 'border-b border-divider-subtle', 'flex space-x-6')}
>
{options.map(option => (
<Item
isActive={option.value === value}

View File

@@ -0,0 +1,107 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useInstalledPluginList } from '@/service/use-plugins'
import TabSlider from './index'
// Mock the service hook
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: vi.fn(),
}))
const mockOptions = [
{ value: 'all', text: 'All' },
{ value: 'plugins', text: 'Plugins' },
{ value: 'settings', text: 'Settings' },
]
describe('TabSlider Component', () => {
const onChangeMock = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useInstalledPluginList).mockReturnValue({
data: { total: 0 },
isLoading: false,
} as ReturnType<typeof useInstalledPluginList>)
})
afterEach(() => {
cleanup()
})
// Helper to inject layout values into JSDOM
const setElementLayout = (id: string, left: number, width: number) => {
const el = document.getElementById(id)
if (el) {
Object.defineProperty(el, 'offsetLeft', { configurable: true, value: left })
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
}
}
it('renders all options correctly', () => {
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
mockOptions.forEach((option) => {
expect(screen.getByText(option.text as string)).toBeInTheDocument()
})
})
it('calls onChange when a new tab is clicked', () => {
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
const pluginTab = screen.getByTestId('tab-item-plugins')
fireEvent.click(pluginTab)
expect(onChangeMock).toHaveBeenCalledWith('plugins')
})
it('applies the correct active classes to the selected tab', () => {
render(<TabSlider value="plugins" options={mockOptions} onChange={onChangeMock} />)
const activeTab = screen.getByTestId('tab-item-plugins')
expect(activeTab).toHaveClass('text-text-primary')
const inactiveTab = screen.getByTestId('tab-item-all')
expect(inactiveTab).toHaveClass('text-text-tertiary')
})
it('renders the Badge when plugins exist and value is "plugins"', () => {
vi.mocked(useInstalledPluginList).mockReturnValue({
data: { total: 5 },
isLoading: false,
} as ReturnType<typeof useInstalledPluginList>)
render(<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('supports functional itemClassName based on active state', () => {
render(
<TabSlider
value="all"
options={mockOptions}
onChange={onChangeMock}
itemClassName={active => (active ? 'is-active-custom' : 'is-inactive-custom')}
/>,
)
expect(screen.getByTestId('tab-item-all')).toHaveClass('is-active-custom')
expect(screen.getByTestId('tab-item-settings')).toHaveClass('is-inactive-custom')
})
it('updates slider styles based on element dimensions', () => {
// 1. Initial Render
const { rerender } = render(
<TabSlider value="all" options={mockOptions} onChange={onChangeMock} />,
)
// 2. Mock layout properties for the elements now that they are in the DOM
setElementLayout('tab-0', 0, 100)
setElementLayout('tab-1', 120, 80)
// 3. Rerender with the same or new value to trigger the useEffect
// This forces updateSliderStyle to run while the mocked values exist
rerender(<TabSlider value="plugins" options={mockOptions} onChange={onChangeMock} />)
const slider = screen.getByTestId('tab-slider-bg')
// Assert the transform matches the "tab-1" (plugins) layout we mocked
expect(slider.style.transform).toBe('translateX(120px)')
expect(slider.style.width).toBe('80px')
})
})

View File

@@ -46,8 +46,12 @@ const TabSlider: FC<TabSliderProps> = ({
}, [value, options, pluginList?.total])
return (
<div className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')}>
<div
data-testid="tab-slider-container"
className={cn(className, 'relative inline-flex items-center justify-center rounded-[10px] bg-components-segmented-control-bg-normal p-0.5')}
>
<div
data-testid="tab-slider-bg"
className="shadows-shadow-xs absolute bottom-0.5 left-0 right-0 top-0.5 rounded-[10px] bg-components-panel-bg transition-transform duration-300 ease-in-out"
style={sliderStyle}
/>
@@ -55,6 +59,7 @@ const TabSlider: FC<TabSliderProps> = ({
<div
id={`tab-${index}`}
key={option.value}
data-testid={`tab-item-${option.value}`}
className={cn(
'relative z-10 flex cursor-pointer items-center justify-center gap-1 rounded-[10px] px-2.5 py-1.5 transition-colors duration-300 ease-in-out',
'system-md-semibold',

View File

@@ -0,0 +1,77 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TextArea from './index'
describe('TextArea', () => {
it('should render correctly with default props', () => {
render(<TextArea value="" onChange={vi.fn()} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeInTheDocument()
expect(textarea).toHaveValue('')
})
it('should handle value and onChange correctly', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveValue('initial')
await user.type(textarea, ' updated')
expect(handleChange).toHaveBeenCalled()
rerender(<TextArea value="initial updated" onChange={handleChange} />)
expect(textarea).toHaveValue('initial updated')
})
it('should handle autoFocus correctly', () => {
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toHaveFocus()
})
it('should handle disabled state', () => {
render(<TextArea value="" onChange={vi.fn()} disabled />)
const textarea = screen.getByTestId('text-area')
expect(textarea).toBeDisabled()
expect(textarea).toHaveClass('cursor-not-allowed')
})
it('should handle placeholder', () => {
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
})
it('should handle className', () => {
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
})
it('should handle size variants', () => {
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
})
it('should handle destructive state', () => {
render(<TextArea value="" onChange={vi.fn()} destructive />)
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
})
it('should handle onFocus and onBlur', async () => {
const user = userEvent.setup()
const handleFocus = vi.fn()
const handleBlur = vi.fn()
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
const textarea = screen.getByTestId('text-area')
await user.click(textarea)
expect(handleFocus).toHaveBeenCalled()
await user.tab()
expect(handleBlur).toHaveBeenCalled()
})
})

View File

@@ -9,9 +9,9 @@ const textareaVariants = cva(
{
variants: {
size: {
small: 'py-1 rounded-md system-xs-regular',
regular: 'px-3 rounded-md system-sm-regular',
large: 'px-4 rounded-lg system-md-regular',
small: 'rounded-md py-1 system-xs-regular',
regular: 'rounded-md px-3 system-sm-regular',
large: 'rounded-lg px-4 system-md-regular',
},
},
defaultVariants: {
@@ -48,6 +48,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
value={value ?? ''}
onChange={onChange}
disabled={disabled}
data-testid="text-area"
{...props}
>
</textarea>

View File

@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TimezoneLabel from './index'
describe('TimezoneLabel', () => {
it('should render correctly with various timezones', () => {
const { rerender } = render(<TimezoneLabel timezone="UTC" />)
const label = screen.getByTestId('timezone-label')
expect(label).toHaveTextContent('UTC+0')
expect(label).toHaveAttribute('title', 'Timezone: UTC (UTC+0)')
rerender(<TimezoneLabel timezone="Asia/Shanghai" />)
expect(label).toHaveTextContent('UTC+8')
expect(label).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)')
rerender(<TimezoneLabel timezone="America/New_York" />)
// New York is UTC-5 or UTC-4 depending on DST.
// dayjs handles this, we just check it renders some offset.
expect(label.textContent).toMatch(/UTC[-+]\d+/)
})
it('should apply correct styling for inline prop', () => {
render(<TimezoneLabel timezone="UTC" inline />)
expect(screen.getByTestId('timezone-label')).toHaveClass('text-text-quaternary')
})
it('should apply custom className', () => {
render(<TimezoneLabel timezone="UTC" className="custom-test-class" />)
expect(screen.getByTestId('timezone-label')).toHaveClass('custom-test-class')
})
})

View File

@@ -43,11 +43,12 @@ const TimezoneLabel: React.FC<TimezoneLabelProps> = ({
return (
<span
className={cn(
'system-sm-regular text-text-tertiary',
'text-text-tertiary system-sm-regular',
inline && 'text-text-quaternary',
className,
)}
title={`Timezone: ${timezone} (${offsetStr})`}
data-testid="timezone-label"
>
{offsetStr}
</span>

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ToolTipContent } from './content'
describe('ToolTipContent', () => {
it('should render children correctly', () => {
render(
<ToolTipContent>
<span>Tooltip body text</span>
</ToolTipContent>,
)
expect(screen.getByTestId('tooltip-content')).toBeInTheDocument()
expect(screen.getByTestId('tooltip-content-body')).toHaveTextContent('Tooltip body text')
expect(screen.queryByTestId('tooltip-content-title')).not.toBeInTheDocument()
expect(screen.queryByTestId('tooltip-content-action')).not.toBeInTheDocument()
})
it('should render title when provided', () => {
render(
<ToolTipContent title="Tooltip Title">
<span>Tooltip body text</span>
</ToolTipContent>,
)
expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title')
})
it('should render action when provided', () => {
render(
<ToolTipContent action={<span>Action Text</span>}>
<span>Tooltip body text</span>
</ToolTipContent>,
)
expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text')
})
it('should handle action click', async () => {
const user = userEvent.setup()
const handleActionClick = vi.fn()
render(
<ToolTipContent action={<span onClick={handleActionClick}>Action Text</span>}>
<span>Tooltip body text</span>
</ToolTipContent>,
)
await user.click(screen.getByText('Action Text'))
expect(handleActionClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -11,12 +11,12 @@ export const ToolTipContent: FC<ToolTipContentProps> = ({
children,
}) => {
return (
<div className="w-[180px]">
<div className="w-[180px]" data-testid="tooltip-content">
{!!title && (
<div className="mb-1.5 font-semibold text-text-secondary">{title}</div>
<div className="mb-1.5 font-semibold text-text-secondary" data-testid="tooltip-content-title">{title}</div>
)}
<div className="mb-1.5 text-text-tertiary">{children}</div>
{!!action && <div className="cursor-pointer text-text-accent">{action}</div>}
<div className="mb-1.5 text-text-tertiary" data-testid="tooltip-content-body">{children}</div>
{!!action && <div className="cursor-pointer text-text-accent" data-testid="tooltip-content-action">{action}</div>}
</div>
)
}

View File

@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import VideoPlayer from './VideoPlayer'
describe('VideoPlayer', () => {
const mockSrc = 'video.mp4'
const mockSrcs = ['video1.mp4', 'video2.mp4']
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
// Mock HTMLVideoElement methods
window.HTMLVideoElement.prototype.play = vi.fn().mockResolvedValue(undefined)
window.HTMLVideoElement.prototype.pause = vi.fn()
window.HTMLVideoElement.prototype.load = vi.fn()
window.HTMLVideoElement.prototype.requestFullscreen = vi.fn().mockResolvedValue(undefined)
// Mock document methods
document.exitFullscreen = vi.fn().mockResolvedValue(undefined)
// Mock offsetWidth to avoid smallSize mode by default
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: 500,
})
// Define properties on HTMLVideoElement prototype
Object.defineProperty(window.HTMLVideoElement.prototype, 'duration', {
configurable: true,
get() { return 100 },
})
// Use a descriptor check to avoid re-defining if it exists
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'currentTime')) {
Object.defineProperty(window.HTMLVideoElement.prototype, 'currentTime', {
configurable: true,
// eslint-disable-next-line ts/no-explicit-any
get() { return (this as any)._currentTime || 0 },
// eslint-disable-next-line ts/no-explicit-any
set(v) { (this as any)._currentTime = v },
})
}
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'volume')) {
Object.defineProperty(window.HTMLVideoElement.prototype, 'volume', {
configurable: true,
// eslint-disable-next-line ts/no-explicit-any
get() { return (this as any)._volume || 1 },
// eslint-disable-next-line ts/no-explicit-any
set(v) { (this as any)._volume = v },
})
}
if (!Object.getOwnPropertyDescriptor(window.HTMLVideoElement.prototype, 'muted')) {
Object.defineProperty(window.HTMLVideoElement.prototype, 'muted', {
configurable: true,
// eslint-disable-next-line ts/no-explicit-any
get() { return (this as any)._muted || false },
// eslint-disable-next-line ts/no-explicit-any
set(v) { (this as any)._muted = v },
})
}
})
describe('Rendering', () => {
it('should render with single src', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-element') as HTMLVideoElement
expect(video.src).toContain(mockSrc)
})
it('should render with multiple srcs', () => {
render(<VideoPlayer srcs={mockSrcs} />)
const sources = screen.getByTestId('video-element').querySelectorAll('source')
expect(sources).toHaveLength(2)
expect(sources[0].src).toContain(mockSrcs[0])
expect(sources[1].src).toContain(mockSrcs[1])
})
})
describe('Interactions', () => {
it('should toggle play/pause on button click', async () => {
const user = userEvent.setup()
render(<VideoPlayer src={mockSrc} />)
const playPauseBtn = screen.getByTestId('video-play-pause-button')
await user.click(playPauseBtn)
expect(window.HTMLVideoElement.prototype.play).toHaveBeenCalled()
await user.click(playPauseBtn)
expect(window.HTMLVideoElement.prototype.pause).toHaveBeenCalled()
})
it('should toggle mute on button click', async () => {
const user = userEvent.setup()
render(<VideoPlayer src={mockSrc} />)
const muteBtn = screen.getByTestId('video-mute-button')
await user.click(muteBtn)
expect(muteBtn).toBeInTheDocument()
})
it('should toggle fullscreen on button click', async () => {
const user = userEvent.setup()
render(<VideoPlayer src={mockSrc} />)
const fullscreenBtn = screen.getByTestId('video-fullscreen-button')
await user.click(fullscreenBtn)
expect(window.HTMLVideoElement.prototype.requestFullscreen).toHaveBeenCalled()
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get() { return {} },
})
await user.click(fullscreenBtn)
expect(document.exitFullscreen).toHaveBeenCalled()
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get() { return null },
})
})
it('should handle video metadata and time updates', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-element') as HTMLVideoElement
fireEvent(video, new Event('loadedmetadata'))
expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:00 / 01:40')
Object.defineProperty(video, 'currentTime', { value: 30, configurable: true })
fireEvent(video, new Event('timeupdate'))
expect(screen.getByTestId('video-time-display')).toHaveTextContent('00:30 / 01:40')
})
it('should handle video end', async () => {
const user = userEvent.setup()
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-element')
const playPauseBtn = screen.getByTestId('video-play-pause-button')
await user.click(playPauseBtn)
fireEvent(video, new Event('ended'))
expect(playPauseBtn).toBeInTheDocument()
})
it('should show/hide controls on mouse move and timeout', () => {
vi.useFakeTimers()
render(<VideoPlayer src={mockSrc} />)
const container = screen.getByTestId('video-player-container')
fireEvent.mouseMove(container)
fireEvent.mouseMove(container) // Trigger clearTimeout
act(() => {
vi.advanceTimersByTime(3001)
})
vi.useRealTimers()
})
it('should handle progress bar interactions', async () => {
const user = userEvent.setup()
render(<VideoPlayer src={mockSrc} />)
const progressBar = screen.getByTestId('video-progress-bar')
const video = screen.getByTestId('video-element') as HTMLVideoElement
vi.spyOn(progressBar, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 100,
top: 0,
right: 100,
bottom: 10,
height: 10,
x: 0,
y: 0,
toJSON: () => { },
} as DOMRect)
// Hover
fireEvent.mouseMove(progressBar, { clientX: 50 })
expect(screen.getByTestId('video-hover-time')).toHaveTextContent('00:50')
fireEvent.mouseLeave(progressBar)
expect(screen.queryByTestId('video-hover-time')).not.toBeInTheDocument()
// Click
await user.click(progressBar)
// Note: user.click calculates clientX based on element position, but we mocked getBoundingClientRect
// RTL fireEvent is more direct for coordinate-based tests
fireEvent.click(progressBar, { clientX: 75 })
expect(video.currentTime).toBe(75)
// Drag
fireEvent.mouseDown(progressBar, { clientX: 20 })
expect(video.currentTime).toBe(20)
fireEvent.mouseMove(document, { clientX: 40 })
expect(video.currentTime).toBe(40)
fireEvent.mouseUp(document)
fireEvent.mouseMove(document, { clientX: 60 })
expect(video.currentTime).toBe(40)
})
it('should handle volume slider change', () => {
render(<VideoPlayer src={mockSrc} />)
const volumeSlider = screen.getByTestId('video-volume-slider')
const video = screen.getByTestId('video-element') as HTMLVideoElement
vi.spyOn(volumeSlider, 'getBoundingClientRect').mockReturnValue({
left: 0,
width: 100,
top: 0,
right: 100,
bottom: 10,
height: 10,
x: 0,
y: 0,
toJSON: () => { },
} as DOMRect)
// Click
fireEvent.click(volumeSlider, { clientX: 50 })
expect(video.volume).toBe(0.5)
// MouseDown and Drag
fireEvent.mouseDown(volumeSlider, { clientX: 80 })
expect(video.volume).toBe(0.8)
fireEvent.mouseMove(document, { clientX: 90 })
expect(video.volume).toBe(0.9)
fireEvent.mouseUp(document) // Trigger cleanup
fireEvent.mouseMove(document, { clientX: 100 })
expect(video.volume).toBe(0.9) // No change after mouseUp
})
it('should handle small size class based on offsetWidth', async () => {
render(<VideoPlayer src={mockSrc} />)
const playerContainer = screen.getByTestId('video-player-container')
Object.defineProperty(playerContainer, 'offsetWidth', { value: 300, configurable: true })
act(() => {
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => {
expect(screen.queryByTestId('video-time-display')).not.toBeInTheDocument()
})
Object.defineProperty(playerContainer, 'offsetWidth', { value: 500, configurable: true })
act(() => {
window.dispatchEvent(new Event('resize'))
})
await waitFor(() => {
expect(screen.getByTestId('video-time-display')).toBeInTheDocument()
})
})
})
})

View File

@@ -215,8 +215,8 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
}, [])
return (
<div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls}>
<video ref={videoRef} src={src} className={styles.video}>
<div ref={containerRef} className={styles.videoPlayer} onMouseMove={showControls} onMouseEnter={showControls} data-testid="video-player-container">
<video ref={videoRef} src={src} className={styles.video} data-testid="video-element">
{/* If srcs array is provided, render multiple source elements */}
{srcs && srcs.map((srcUrl, index) => (
<source key={index} src={srcUrl} />
@@ -232,12 +232,14 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onMouseDown={handleMouseDown}
data-testid="video-progress-bar"
>
<div className={styles.progress} style={{ width: `${(currentTime / duration) * 100}%` }} />
{hoverTime !== null && (
<div
className={styles.hoverTimeIndicator}
style={{ left: `${(hoverTime / duration) * 100}%` }}
data-testid="video-hover-time"
>
{formatTime(hoverTime)}
</div>
@@ -246,11 +248,11 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
</div>
<div className={styles.controlsContent}>
<div className={styles.leftControls}>
<button type="button" className={styles.playPauseButton} onClick={togglePlayPause}>
<button type="button" className={styles.playPauseButton} onClick={togglePlayPause} data-testid="video-play-pause-button">
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
{!isSmallSize && (
<span className={styles.time}>
<span className={styles.time} data-testid="video-time-display">
{formatTime(currentTime)}
{' '}
/
@@ -260,7 +262,7 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
)}
</div>
<div className={styles.rightControls}>
<button type="button" className={styles.muteButton} onClick={toggleMute}>
<button type="button" className={styles.muteButton} onClick={toggleMute} data-testid="video-mute-button">
{isMuted ? <UnmuteIcon /> : <MuteIcon />}
</button>
{!isSmallSize && (
@@ -279,12 +281,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ src, srcs }) => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}}
data-testid="video-volume-slider"
>
<div className={styles.volumeLevel} style={{ width: `${volume * 100}%` }} />
</div>
</div>
)}
<button type="button" className={styles.fullscreenButton} onClick={toggleFullscreen}>
<button type="button" className={styles.fullscreenButton} onClick={toggleFullscreen} data-testid="video-fullscreen-button">
<FullscreenIcon />
</button>
</div>

View File

@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import VideoGallery from './index'
describe('VideoGallery', () => {
const mockSrcs = ['video1.mp4', 'video2.mp4']
it('should render nothing when srcs is empty', () => {
const { container } = render(<VideoGallery srcs={[]} />)
expect(container).toBeEmptyDOMElement()
})
it('should render nothing when all srcs are empty strings', () => {
const { container } = render(<VideoGallery srcs={['', '']} />)
expect(container).toBeEmptyDOMElement()
})
it('should render VideoPlayer when valid srcs are provided', () => {
render(<VideoGallery srcs={mockSrcs} />)
expect(screen.getByTestId('video-gallery-container')).toBeInTheDocument()
expect(screen.getByTestId('video-element')).toBeInTheDocument()
})
})

View File

@@ -11,7 +11,7 @@ const VideoGallery: React.FC<Props> = ({ srcs }) => {
return null
return (
<div className="my-3">
<div className="my-3" data-testid="video-gallery-container">
<VideoPlayer srcs={validSrcs} />
</div>
)

View File

@@ -0,0 +1,310 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { audioToText } from '@/service/share'
import VoiceInput from './index'
const { mockState, MockRecorder } = vi.hoisted(() => {
const state = {
params: {} as Record<string, string>,
pathname: '/test',
rafCallback: undefined as (() => void) | undefined,
recorderInstances: [] as unknown[],
startOverride: null as (() => Promise<void>) | null,
analyseData: new Uint8Array(1024).fill(150) as Uint8Array,
}
class MockRecorderClass {
start = vi.fn((..._args: unknown[]) => {
if (state.startOverride)
return state.startOverride()
return Promise.resolve()
})
stop = vi.fn()
getRecordAnalyseData = vi.fn(() => state.analyseData)
getWAV = vi.fn(() => new ArrayBuffer(0))
getChannelData = vi.fn(() => ({
left: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
right: { buffer: new ArrayBuffer(2048), byteLength: 2048 },
}))
constructor() {
state.recorderInstances.push(this)
}
}
return { mockState: state, MockRecorder: MockRecorderClass }
})
vi.mock('js-audio-recorder', () => ({
default: MockRecorder,
}))
vi.mock('@/service/share', () => ({
AppSourceType: { webApp: 'webApp', installedApp: 'installedApp' },
audioToText: vi.fn(),
}))
vi.mock('next/navigation', () => ({
useParams: vi.fn(() => mockState.params),
usePathname: vi.fn(() => mockState.pathname),
}))
vi.mock('./utils', () => ({
convertToMp3: vi.fn(() => new Blob(['test'], { type: 'audio/mp3' })),
}))
vi.mock('ahooks', () => ({
useRafInterval: vi.fn((fn: () => void) => {
mockState.rafCallback = fn
return vi.fn()
}),
}))
describe('VoiceInput', () => {
const onConverted = vi.fn()
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockState.params = {}
mockState.pathname = '/test'
mockState.rafCallback = undefined
mockState.recorderInstances = []
mockState.startOverride = null
// Ensure canvas has non-zero dimensions for initCanvas()
HTMLCanvasElement.prototype.getBoundingClientRect = vi.fn(() => ({
width: 300,
height: 32,
top: 0,
left: 0,
right: 300,
bottom: 32,
x: 0,
y: 0,
toJSON: vi.fn(),
}))
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { })
})
it('should start recording on mount and show speaking state', async () => {
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
expect(recorder.start).toHaveBeenCalled()
expect(await screen.findByText('common.voiceInput.speaking')).toBeInTheDocument()
expect(screen.getByTestId('voice-input-stop')).toBeInTheDocument()
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:00')
})
it('should increment timer via useRafInterval callback', async () => {
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await screen.findByText('common.voiceInput.speaking')
act(() => {
mockState.rafCallback?.()
})
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:01')
act(() => {
mockState.rafCallback?.()
})
expect(screen.getByTestId('voice-input-timer')).toHaveTextContent('00:02')
})
it('should call onCancel when recording start fails', async () => {
mockState.startOverride = () => Promise.reject(new Error('Permission denied'))
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(onCancel).toHaveBeenCalled()
})
})
it('should stop recording and convert audio on stop click', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'hello world' })
mockState.params = { token: 'abc' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
const stopBtn = await screen.findByTestId('voice-input-stop')
await user.click(stopBtn)
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
expect(await screen.findByTestId('voice-input-converting-text')).toBeInTheDocument()
expect(screen.getByText('common.voiceInput.converting')).toBeInTheDocument()
expect(screen.getByTestId('voice-input-loader')).toBeInTheDocument()
await waitFor(() => {
expect(recorder.stop).toHaveBeenCalled()
expect(onConverted).toHaveBeenCalledWith('hello world')
expect(onCancel).toHaveBeenCalled()
})
})
it('should call onConverted with empty string on conversion failure', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockRejectedValueOnce(new Error('API error'))
mockState.params = { token: 'abc' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
const stopBtn = await screen.findByTestId('voice-input-stop')
await user.click(stopBtn)
await waitFor(() => {
expect(onConverted).toHaveBeenCalledWith('')
expect(onCancel).toHaveBeenCalled()
})
})
it('should show cancel button during conversion and cancel on click', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockImplementation(() => new Promise(() => { }))
mockState.params = { token: 'abc' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
const stopBtn = await screen.findByTestId('voice-input-stop')
await user.click(stopBtn)
const cancelBtn = await screen.findByTestId('voice-input-cancel')
await user.click(cancelBtn)
expect(onCancel).toHaveBeenCalled()
})
it('should automatically stop recording after 600 seconds', async () => {
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'auto stopped' })
mockState.params = { token: 'abc' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await screen.findByTestId('voice-input-stop')
for (let i = 0; i < 600; i++)
act(() => { mockState.rafCallback?.() })
await waitFor(() => {
expect(onConverted).toHaveBeenCalledWith('auto stopped')
})
})
it('should show red timer text after 500 seconds', async () => {
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await screen.findByTestId('voice-input-stop')
for (let i = 0; i < 501; i++)
act(() => { mockState.rafCallback?.() })
const timer = screen.getByTestId('voice-input-timer')
expect(timer.className).toContain('text-[#F04438]')
})
it('should draw on canvas with low data values triggering v < 128 clamp', async () => {
mockState.analyseData = new Uint8Array(1024).fill(50)
let rafCalls = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCalls++
if (rafCalls <= 2)
cb(0)
return rafCalls
})
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await screen.findByTestId('voice-input-stop')
// eslint-disable-next-line ts/no-explicit-any
const firstRecorder = mockState.recorderInstances[0] as any
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
})
it('should draw on canvas with high data values triggering v > 178 clamp', async () => {
mockState.analyseData = new Uint8Array(1024).fill(250)
let rafCalls = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCalls++
if (rafCalls <= 2)
cb(0)
return rafCalls
})
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await screen.findByTestId('voice-input-stop')
// eslint-disable-next-line ts/no-explicit-any
const firstRecorder = mockState.recorderInstances[0] as any
expect(firstRecorder.getRecordAnalyseData).toHaveBeenCalled()
})
it('should pass wordTimestamps in form data', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
mockState.params = { token: 'abc' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} wordTimestamps="enabled" />)
const stopBtn = await screen.findByTestId('voice-input-stop')
await user.click(stopBtn)
await waitFor(() => {
expect(audioToText).toHaveBeenCalled()
const formData = vi.mocked(audioToText).mock.calls[0][2] as FormData
expect(formData.get('word_timestamps')).toBe('enabled')
})
})
describe('URL patterns', () => {
it('should use webApp source with /audio-to-text for token-based URL', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
mockState.params = { token: 'my-token' }
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await user.click(await screen.findByTestId('voice-input-stop'))
await waitFor(() => {
expect(audioToText).toHaveBeenCalledWith('/audio-to-text', 'webApp', expect.any(FormData))
})
})
it('should use installed-apps URL when pathname includes explore/installed', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
mockState.params = { appId: 'app-123' }
mockState.pathname = '/explore/installed'
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await user.click(await screen.findByTestId('voice-input-stop'))
await waitFor(() => {
expect(audioToText).toHaveBeenCalledWith(
'/installed-apps/app-123/audio-to-text',
'installedApp',
expect.any(FormData),
)
})
})
it('should use /apps URL for non-explore paths with appId', async () => {
const user = userEvent.setup()
vi.mocked(audioToText).mockResolvedValueOnce({ text: 'test' })
mockState.params = { appId: 'app-456' }
mockState.pathname = '/dashboard/apps'
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await user.click(await screen.findByTestId('voice-input-stop'))
await waitFor(() => {
expect(audioToText).toHaveBeenCalledWith(
'/apps/app-456/audio-to-text',
'installedApp',
expect.any(FormData),
)
})
})
})
})

View File

@@ -1,13 +1,8 @@
import {
RiCloseLine,
RiLoader2Line,
} from '@remixicon/react'
import { useRafInterval } from 'ahooks'
import Recorder from 'js-audio-recorder'
import { useParams, usePathname } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { AppSourceType, audioToText } from '@/service/share'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
@@ -117,7 +112,7 @@ const VoiceInput = ({
onCancel()
}
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
const handleStartRecord = async () => {
const handleStartRecord = useCallback(async () => {
try {
await recorder.current.start()
setStartRecord(true)
@@ -129,9 +124,8 @@ const VoiceInput = ({
catch {
onCancel()
}
}
const initCanvas = () => {
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
const initCanvas = useCallback(() => {
const dpr = window.devicePixelRatio || 1
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
@@ -149,7 +143,7 @@ const VoiceInput = ({
ctxRef.current = ctx
}
}
}
}, [])
if (originDuration >= 600 && startRecord)
handleStopRecorder()
@@ -160,7 +154,7 @@ const VoiceInput = ({
return () => {
recorderRef?.stop()
}
}, [])
}, [handleStartRecord, initCanvas])
const minutes = Number.parseInt(`${Number.parseInt(`${originDuration}`) / 60}`)
const seconds = Number.parseInt(`${originDuration}`) % 60
@@ -170,7 +164,7 @@ const VoiceInput = ({
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
<canvas id="voice-input-record" className="absolute bottom-0 left-0 h-4 w-full" />
{
startConvert && <RiLoader2Line className="mr-2 h-4 w-4 animate-spin text-primary-700" />
startConvert && <div className="i-ri-loader-2-line mr-2 h-4 w-4 animate-spin text-primary-700" data-testid="voice-input-loader" />
}
<div className="grow">
{
@@ -182,7 +176,7 @@ const VoiceInput = ({
}
{
startConvert && (
<div className={cn(s.convert, 'text-sm')}>
<div className={cn(s.convert, 'text-sm')} data-testid="voice-input-converting-text">
{t('voiceInput.converting', { ns: 'common' })}
</div>
)
@@ -191,24 +185,26 @@ const VoiceInput = ({
{
startRecord && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
onClick={handleStopRecorder}
data-testid="voice-input-stop"
>
<StopCircle className="h-5 w-5 text-primary-600" />
<div className="i-ri-stop-circle-line h-5 w-5 text-primary-600" />
</div>
)
}
{
startConvert && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
onClick={onCancel}
data-testid="voice-input-cancel"
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<div className="i-ri-close-line h-4 w-4 text-gray-500" />
</div>
)
}
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`}>{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
<div className={`w-[45px] pl-1 text-xs font-medium ${originDuration > 500 ? 'text-[#F04438]' : 'text-gray-700'}`} data-testid="voice-input-timer">{`0${minutes.toFixed(0)}:${seconds >= 10 ? seconds : `0${seconds}`}`}</div>
</div>
</div>
)

View File

@@ -0,0 +1,126 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Zendesk from './index'
// Shared state for mocks
let mockIsCeEdition = false
let mockZendeskWidgetKey: string | undefined = 'test-key'
let mockIsProd = false
let mockNonce: string | null = 'test-nonce'
// Mock react's memo to just return the function
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>()
return {
...actual,
memo: vi.fn(fn => fn),
}
})
// Mock config
vi.mock('@/config', () => ({
get IS_CE_EDITION() { return mockIsCeEdition },
get ZENDESK_WIDGET_KEY() { return mockZendeskWidgetKey },
get IS_PROD() { return mockIsProd },
}))
// Mock next/headers
vi.mock('next/headers', () => ({
headers: vi.fn(() => ({
get: vi.fn((name: string) => {
if (name === 'x-nonce')
return mockNonce
return null
}),
})),
}))
// Mock next/script
type ScriptProps = {
'children'?: ReactNode
'id'?: string
'src'?: string
'nonce'?: string
'data-testid'?: string
}
vi.mock('next/script', () => ({
__esModule: true,
default: vi.fn(({ children, id, src, nonce, 'data-testid': testId }: ScriptProps) => (
<div data-testid={testId} id={id} data-src={src} data-nonce={nonce}>
{children}
</div>
)),
}))
describe('Zendesk', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCeEdition = false
mockZendeskWidgetKey = 'test-key'
mockIsProd = false
mockNonce = 'test-nonce'
})
// Helper to call the async component
const renderZendesk = async () => {
const Component = Zendesk as unknown as () => Promise<ReactNode>
return await Component()
}
it('should render nothing when IS_CE_EDITION is true', async () => {
mockIsCeEdition = true
const result = await renderZendesk()
expect(result).toBeNull()
})
it('should render nothing when ZENDESK_WIDGET_KEY is missing', async () => {
mockZendeskWidgetKey = undefined
const result = await renderZendesk()
expect(result).toBeNull()
})
it('should render scripts correctly in non-production environment', async () => {
mockIsProd = false
const result = await renderZendesk()
render(result as React.ReactElement) // result is ReactNode, which render accepts but types might be picky
const snippet = screen.getByTestId('ze-snippet')
expect(snippet).toBeInTheDocument()
expect(snippet).toHaveAttribute('id', 'ze-snippet')
expect(snippet).toHaveAttribute('data-src', 'https://static.zdassets.com/ekr/snippet.js?key=test-key')
expect(snippet).toHaveAttribute('data-nonce', '')
const init = screen.getByTestId('ze-init')
expect(init).toBeInTheDocument()
expect(init).toHaveAttribute('id', 'ze-init')
expect(init).toHaveTextContent('window.zE(\'messenger\', \'hide\')')
expect(init).toHaveAttribute('data-nonce', '')
})
it('should render scripts with nonce in production environment', async () => {
mockIsProd = true
mockNonce = 'prod-nonce'
const result = await renderZendesk()
render(result as React.ReactElement)
const snippet = screen.getByTestId('ze-snippet')
expect(snippet).toHaveAttribute('data-nonce', 'prod-nonce')
const init = screen.getByTestId('ze-init')
expect(init).toHaveAttribute('data-nonce', 'prod-nonce')
})
it('should render scripts with empty nonce in production when header is missing', async () => {
mockIsProd = true
mockNonce = null
const result = await renderZendesk()
render(result as React.ReactElement)
const snippet = screen.getByTestId('ze-snippet')
expect(snippet).toHaveAttribute('data-nonce', '')
const init = screen.getByTestId('ze-init')
expect(init).toHaveAttribute('data-nonce', '')
})
})

View File

@@ -15,8 +15,9 @@ const Zendesk = async () => {
nonce={nonce ?? undefined}
id="ze-snippet"
src={`https://static.zdassets.com/ekr/snippet.js?key=${ZENDESK_WIDGET_KEY}`}
data-testid="ze-snippet"
/>
<Script nonce={nonce ?? undefined} id="ze-init">
<Script nonce={nonce ?? undefined} id="ze-init" data-testid="ze-init">
{`
(function () {
window.addEventListener('load', function () {

View File

@@ -2722,16 +2722,6 @@
"count": 1
}
},
"app/components/base/tab-header/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/tab-slider-plain/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 2
}
},
"app/components/base/tab-slider/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -2781,14 +2771,6 @@
"app/components/base/textarea/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/base/timezone-label/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/base/toast/index.tsx": {
@@ -2817,11 +2799,6 @@
"count": 1
}
},
"app/components/base/voice-input/index.tsx": {
"tailwindcss/no-unnecessary-whitespace": {
"count": 2
}
},
"app/components/base/voice-input/utils.ts": {
"ts/no-explicit-any": {
"count": 4