From 6e531fe44fb262001e9b12fa3d1e32440357efd0 Mon Sep 17 00:00:00 2001 From: Poojan Date: Tue, 24 Feb 2026 09:51:02 +0530 Subject: [PATCH] test: add unit tests for base-components part-3 (#32408) --- .../components/base/tab-header/index.spec.tsx | 114 +++++++ web/app/components/base/tab-header/index.tsx | 9 +- .../base/tab-slider-new/index.spec.tsx | 99 ++++++ .../components/base/tab-slider-new/index.tsx | 6 +- .../base/tab-slider-plain/index.spec.tsx | 100 ++++++ .../base/tab-slider-plain/index.tsx | 21 +- .../components/base/tab-slider/index.spec.tsx | 107 ++++++ web/app/components/base/tab-slider/index.tsx | 7 +- .../components/base/textarea/index.spec.tsx | 77 +++++ web/app/components/base/textarea/index.tsx | 7 +- .../base/timezone-label/index.spec.tsx | 31 ++ .../components/base/timezone-label/index.tsx | 3 +- .../components/base/tooltip/content.spec.tsx | 49 +++ web/app/components/base/tooltip/content.tsx | 8 +- .../base/video-gallery/VideoPlayer.spec.tsx | 262 +++++++++++++++ .../base/video-gallery/VideoPlayer.tsx | 15 +- .../base/video-gallery/index.spec.tsx | 23 ++ .../components/base/video-gallery/index.tsx | 2 +- .../base/voice-input/index.spec.tsx | 310 ++++++++++++++++++ web/app/components/base/voice-input/index.tsx | 32 +- .../components/base/zendesk/index.spec.tsx | 126 +++++++ web/app/components/base/zendesk/index.tsx | 3 +- web/eslint-suppressions.json | 23 -- 23 files changed, 1367 insertions(+), 67 deletions(-) create mode 100644 web/app/components/base/tab-header/index.spec.tsx create mode 100644 web/app/components/base/tab-slider-new/index.spec.tsx create mode 100644 web/app/components/base/tab-slider-plain/index.spec.tsx create mode 100644 web/app/components/base/tab-slider/index.spec.tsx create mode 100644 web/app/components/base/textarea/index.spec.tsx create mode 100644 web/app/components/base/timezone-label/index.spec.tsx create mode 100644 web/app/components/base/tooltip/content.spec.tsx create mode 100644 web/app/components/base/video-gallery/VideoPlayer.spec.tsx create mode 100644 web/app/components/base/video-gallery/index.spec.tsx create mode 100644 web/app/components/base/voice-input/index.spec.tsx create mode 100644 web/app/components/base/zendesk/index.spec.tsx diff --git a/web/app/components/base/tab-header/index.spec.tsx b/web/app/components/base/tab-header/index.spec.tsx new file mode 100644 index 0000000000..df0a827e57 --- /dev/null +++ b/web/app/components/base/tab-header/index.spec.tsx @@ -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( { }} />) + + 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( { }} />) + + 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( + { }} + />, + ) + + 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() + + 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() + + 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: 🚀, + extra: New, + }, + ] + render( { }} />) + + expect(screen.getByTestId('tab-icon')).toBeInTheDocument() + expect(screen.getByTestId('tab-extra')).toBeInTheDocument() + }) + + it('should apply custom class names for items and wrappers', () => { + render( + { }} + />, + ) + + 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') + }) +}) diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx index 6ba6a354a3..e5f0556644 100644 --- a/web/app/components/base/tab-header/index.tsx +++ b/web/app/components/base/tab-header/index.tsx @@ -32,8 +32,9 @@ const TabHeader: FC = ({ const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
= ({
) return ( -
-
+
+
{items.filter(item => !item.isRight).map(renderItem)}
-
+
{items.filter(item => item.isRight).map(renderItem)}
diff --git a/web/app/components/base/tab-slider-new/index.spec.tsx b/web/app/components/base/tab-slider-new/index.spec.tsx new file mode 100644 index 0000000000..d47afb2aed --- /dev/null +++ b/web/app/components/base/tab-slider-new/index.spec.tsx @@ -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: ico }, + ] + + it('should render all options with text and icons', () => { + render( + { }} + />, + ) + + 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( + { }} + />, + ) + + 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( + , + ) + + 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( + { }} + 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( + , + ) + + const activeTab = screen.getByTestId('tab-item-all') + await user.click(activeTab) + + expect(handleChange).toHaveBeenCalledWith('all') + }) +}) diff --git a/web/app/components/base/tab-slider-new/index.tsx b/web/app/components/base/tab-slider-new/index.tsx index 464226ee02..96b97a55f6 100644 --- a/web/app/components/base/tab-slider-new/index.tsx +++ b/web/app/components/base/tab-slider-new/index.tsx @@ -19,10 +19,14 @@ const TabSliderNew: FC = ({ options, }) => { return ( -
+
{options.map(option => (
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', diff --git a/web/app/components/base/tab-slider-plain/index.spec.tsx b/web/app/components/base/tab-slider-plain/index.spec.tsx new file mode 100644 index 0000000000..40c5b8c329 --- /dev/null +++ b/web/app/components/base/tab-slider-plain/index.spec.tsx @@ -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: Advanced }, + ] + + it('should render all options correctly', () => { + render( { }} />) + + 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() + + 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() + + 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( { }} />) + + 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( { }} 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( { }} />) + + 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( + { }} />, + ) + expect(screen.getByTestId('tab-slider')).toHaveClass('border-b') + + rerender( + { }} noBorderBottom />, + ) + expect(screen.getByTestId('tab-slider')).not.toHaveClass('border-b') + }) + + it('should apply custom itemClassName to all items', () => { + const customClass = 'my-custom-item' + render( + { }} + itemClassName={customClass} + />, + ) + + expect(screen.getByTestId('tab-slider-item-tab1')).toHaveClass(customClass) + expect(screen.getByTestId('tab-slider-item-tab2')).toHaveClass(customClass) + }) +}) diff --git a/web/app/components/base/tab-slider-plain/index.tsx b/web/app/components/base/tab-slider-plain/index.tsx index 5b8eb270ee..106d234016 100644 --- a/web/app/components/base/tab-slider-plain/index.tsx +++ b/web/app/components/base/tab-slider-plain/index.tsx @@ -25,17 +25,27 @@ const Item: FC = ({ return (
!isActive && onClick(option.value)} > -
{option.text}
+
+ {option.text} +
{isActive && ( -
+
+
)}
) @@ -61,7 +71,10 @@ const TabSlider: FC = ({ smallItem, }) => { return ( -
+
{options.map(option => ( ({ + 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) + }) + + 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() + mockOptions.forEach((option) => { + expect(screen.getByText(option.text as string)).toBeInTheDocument() + }) + }) + + it('calls onChange when a new tab is clicked', () => { + render() + 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() + 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) + + render() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('supports functional itemClassName based on active state', () => { + render( + (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( + , + ) + + // 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() + + 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') + }) +}) diff --git a/web/app/components/base/tab-slider/index.tsx b/web/app/components/base/tab-slider/index.tsx index ceb4322045..c7a8fba1d1 100644 --- a/web/app/components/base/tab-slider/index.tsx +++ b/web/app/components/base/tab-slider/index.tsx @@ -46,8 +46,12 @@ const TabSlider: FC = ({ }, [value, options, pluginList?.total]) return ( -
+
@@ -55,6 +59,7 @@ const TabSlider: FC = ({
{ + it('should render correctly with default props', () => { + render( diff --git a/web/app/components/base/timezone-label/index.spec.tsx b/web/app/components/base/timezone-label/index.spec.tsx new file mode 100644 index 0000000000..c43aa61936 --- /dev/null +++ b/web/app/components/base/timezone-label/index.spec.tsx @@ -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() + const label = screen.getByTestId('timezone-label') + expect(label).toHaveTextContent('UTC+0') + expect(label).toHaveAttribute('title', 'Timezone: UTC (UTC+0)') + + rerender() + expect(label).toHaveTextContent('UTC+8') + expect(label).toHaveAttribute('title', 'Timezone: Asia/Shanghai (UTC+8)') + + rerender() + // 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() + expect(screen.getByTestId('timezone-label')).toHaveClass('text-text-quaternary') + }) + + it('should apply custom className', () => { + render() + expect(screen.getByTestId('timezone-label')).toHaveClass('custom-test-class') + }) +}) diff --git a/web/app/components/base/timezone-label/index.tsx b/web/app/components/base/timezone-label/index.tsx index f614280b3e..bb4355f338 100644 --- a/web/app/components/base/timezone-label/index.tsx +++ b/web/app/components/base/timezone-label/index.tsx @@ -43,11 +43,12 @@ const TimezoneLabel: React.FC = ({ return ( {offsetStr} diff --git a/web/app/components/base/tooltip/content.spec.tsx b/web/app/components/base/tooltip/content.spec.tsx new file mode 100644 index 0000000000..314c773ce1 --- /dev/null +++ b/web/app/components/base/tooltip/content.spec.tsx @@ -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( + + Tooltip body text + , + ) + 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( + + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-title')).toHaveTextContent('Tooltip Title') + }) + + it('should render action when provided', () => { + render( + Action Text}> + Tooltip body text + , + ) + expect(screen.getByTestId('tooltip-content-action')).toHaveTextContent('Action Text') + }) + + it('should handle action click', async () => { + const user = userEvent.setup() + const handleActionClick = vi.fn() + render( + Action Text}> + Tooltip body text + , + ) + + await user.click(screen.getByText('Action Text')) + expect(handleActionClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/tooltip/content.tsx b/web/app/components/base/tooltip/content.tsx index 1879e077e5..a5a31a2a5c 100644 --- a/web/app/components/base/tooltip/content.tsx +++ b/web/app/components/base/tooltip/content.tsx @@ -11,12 +11,12 @@ export const ToolTipContent: FC = ({ children, }) => { return ( -
+
{!!title && ( -
{title}
+
{title}
)} -
{children}
- {!!action &&
{action}
} +
{children}
+ {!!action &&
{action}
}
) } diff --git a/web/app/components/base/video-gallery/VideoPlayer.spec.tsx b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx new file mode 100644 index 0000000000..04d9ccc4c8 --- /dev/null +++ b/web/app/components/base/video-gallery/VideoPlayer.spec.tsx @@ -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() + const video = screen.getByTestId('video-element') as HTMLVideoElement + expect(video.src).toContain(mockSrc) + }) + + it('should render with multiple srcs', () => { + render() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + }) + }) + }) +}) diff --git a/web/app/components/base/video-gallery/VideoPlayer.tsx b/web/app/components/base/video-gallery/VideoPlayer.tsx index 8adaf71f58..6b2d802863 100644 --- a/web/app/components/base/video-gallery/VideoPlayer.tsx +++ b/web/app/components/base/video-gallery/VideoPlayer.tsx @@ -215,8 +215,8 @@ const VideoPlayer: React.FC = ({ src, srcs }) => { }, []) return ( -
-