From 84d090db33c6cd0d7c6f3139b03eb3a5e4fb6797 Mon Sep 17 00:00:00 2001 From: Poojan Date: Fri, 13 Feb 2026 08:44:14 +0530 Subject: [PATCH] test: add unit tests for base components-part-1 (#32154) --- .../base/content-dialog/index.spec.tsx | 59 +++++ web/app/components/base/dialog/index.spec.tsx | 138 +++++++++++ .../base/fullscreen-modal/index.spec.tsx | 214 ++++++++++++++++++ .../base/new-audio-button/index.spec.tsx | 205 +++++++++++++++++ .../base/notion-connector/index.spec.tsx | 49 ++++ .../components/base/skeleton/index.spec.tsx | 83 +++++++ web/app/components/base/slider/index.spec.tsx | 77 +++++++ web/app/components/base/sort/index.spec.tsx | 141 ++++++++++++ web/app/components/base/switch/index.spec.tsx | 84 +++++++ web/app/components/base/tag/index.spec.tsx | 104 +++++++++ 10 files changed, 1154 insertions(+) create mode 100644 web/app/components/base/content-dialog/index.spec.tsx create mode 100644 web/app/components/base/dialog/index.spec.tsx create mode 100644 web/app/components/base/fullscreen-modal/index.spec.tsx create mode 100644 web/app/components/base/new-audio-button/index.spec.tsx create mode 100644 web/app/components/base/notion-connector/index.spec.tsx create mode 100644 web/app/components/base/skeleton/index.spec.tsx create mode 100644 web/app/components/base/slider/index.spec.tsx create mode 100644 web/app/components/base/sort/index.spec.tsx create mode 100644 web/app/components/base/switch/index.spec.tsx create mode 100644 web/app/components/base/tag/index.spec.tsx diff --git a/web/app/components/base/content-dialog/index.spec.tsx b/web/app/components/base/content-dialog/index.spec.tsx new file mode 100644 index 0000000000..a047fdf062 --- /dev/null +++ b/web/app/components/base/content-dialog/index.spec.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ContentDialog from './index' + +describe('ContentDialog', () => { + it('renders children when show is true', async () => { + render( + +
Dialog body
+
, + ) + + await screen.findByText('Dialog body') + expect(screen.getByText('Dialog body')).toBeInTheDocument() + + const backdrop = document.querySelector('.bg-app-detail-overlay-bg') + expect(backdrop).toBeTruthy() + }) + + it('does not render children when show is false', () => { + render( + +
Hidden content
+
, + ) + + expect(screen.queryByText('Hidden content')).toBeNull() + expect(document.querySelector('.bg-app-detail-overlay-bg')).toBeNull() + }) + + it('calls onClose when backdrop is clicked', async () => { + const onClose = vi.fn() + render( + +
Body
+
, + ) + + const user = userEvent.setup() + const backdrop = document.querySelector('.bg-app-detail-overlay-bg') as HTMLElement | null + expect(backdrop).toBeTruthy() + + await user.click(backdrop!) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('applies provided className to the content panel', () => { + render( + +
Panel content
+
, + ) + + const contentPanel = document.querySelector('.bg-app-detail-bg') as HTMLElement | null + expect(contentPanel).toBeTruthy() + expect(contentPanel?.className).toContain('my-panel-class') + expect(screen.getByText('Panel content')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/dialog/index.spec.tsx b/web/app/components/base/dialog/index.spec.tsx new file mode 100644 index 0000000000..c58724595f --- /dev/null +++ b/web/app/components/base/dialog/index.spec.tsx @@ -0,0 +1,138 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import CustomDialog from './index' + +describe('CustomDialog Component', () => { + const setup = () => userEvent.setup() + + it('should render children and title when show is true', async () => { + render( + +
Main Content
+
, + ) + + const title = await screen.findByText('Modal Title') + const content = screen.getByTestId('dialog-content') + + expect(title).toBeInTheDocument() + expect(content).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should not render anything when show is false', async () => { + render( + +
Content
+
, + ) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument() + }) + + it('should apply the correct semantic tag to title using titleAs', async () => { + render( + + Content + , + ) + + const title = await screen.findByRole('heading', { level: 1 }) + expect(title).toHaveTextContent('Semantic Title') + }) + + it('should render the footer only when the prop is provided', async () => { + const { rerender } = render( + Content, + ) + + await screen.findByRole('dialog') + expect(screen.queryByText('Footer Content')).not.toBeInTheDocument() + + rerender( + Footer Content}> + Content + , + ) + + expect(await screen.findByTestId('footer-node')).toBeInTheDocument() + }) + + it('should call onClose when Escape key is pressed', async () => { + const user = setup() + const onCloseMock = vi.fn() + + render( + + Content + , + ) + + await screen.findByRole('dialog') + + await act(async () => { + await user.keyboard('{Escape}') + }) + + expect(onCloseMock).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when the backdrop is clicked', async () => { + const user = setup() + const onCloseMock = vi.fn() + + render( + + Content + , + ) + + await screen.findByRole('dialog') + + const backdrop = document.querySelector('.bg-background-overlay-backdrop') + expect(backdrop).toBeInTheDocument() + + await act(async () => { + await user.click(backdrop!) + }) + + expect(onCloseMock).toHaveBeenCalledTimes(1) + }) + + it('should apply custom class names to internal elements', async () => { + render( + +
Content
+
, + ) + + await screen.findByRole('dialog') + + expect(document.querySelector('.custom-panel-container')).toBeInTheDocument() + expect(document.querySelector('.custom-title-style')).toBeInTheDocument() + expect(document.querySelector('.custom-body-style')).toBeInTheDocument() + expect(document.querySelector('.custom-footer-style')).toBeInTheDocument() + }) + + it('should maintain accessibility attributes (aria-modal)', async () => { + render( + + + , + ) + + const dialog = await screen.findByRole('dialog') + // Headless UI should automatically set aria-modal="true" + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) +}) diff --git a/web/app/components/base/fullscreen-modal/index.spec.tsx b/web/app/components/base/fullscreen-modal/index.spec.tsx new file mode 100644 index 0000000000..cf1484fc63 --- /dev/null +++ b/web/app/components/base/fullscreen-modal/index.spec.tsx @@ -0,0 +1,214 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import FullScreenModal from './index' + +describe('FullScreenModal Component', () => { + it('should not render anything when open is false', () => { + render( + +
Content
+
, + ) + expect(screen.queryByTestId('modal-content')).not.toBeInTheDocument() + }) + + it('should render content when open is true', async () => { + render( + +
Content
+
, + ) + expect(await screen.findByTestId('modal-content')).toBeInTheDocument() + }) + + it('should not crash when provided with title and description props', async () => { + await act(async () => { + render( + + Content + , + ) + }) + }) + + describe('Props Handling', () => { + it('should apply wrapperClassName to the dialog root', async () => { + render( + + Content + , + ) + + await screen.findByRole('dialog') + const element = document.querySelector('.custom-wrapper-class') + expect(element).toBeInTheDocument() + expect(element).toHaveClass('modal-dialog') + }) + + it('should apply className to the inner panel', async () => { + await act(async () => { + render( + + Content + , + ) + }) + const panel = document.querySelector('.custom-panel-class') + expect(panel).toBeInTheDocument() + expect(panel).toHaveClass('h-full') + }) + + it('should handle overflowVisible prop', async () => { + const { rerender } = await act(async () => { + return render( + + Content + , + ) + }) + let panel = document.querySelector('.target-panel') + expect(panel).toHaveClass('overflow-visible') + expect(panel).not.toHaveClass('overflow-hidden') + + await act(async () => { + rerender( + + Content + , + ) + }) + panel = document.querySelector('.target-panel') + expect(panel).toHaveClass('overflow-hidden') + expect(panel).not.toHaveClass('overflow-visible') + }) + + it('should render close button when closable is true', async () => { + await act(async () => { + render( + + Content + , + ) + }) + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeButton).toBeInTheDocument() + }) + + it('should not render close button when closable is false', async () => { + await act(async () => { + render( + + Content + , + ) + }) + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeButton).not.toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should call onClose when close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + + Content + , + ) + + const closeBtn = document.querySelector('.bg-components-button-tertiary-bg') + expect(closeBtn).toBeInTheDocument() + + await user.click(closeBtn!) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should call onClose when clicking the backdrop', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + +
Content
+
, + ) + + const dialog = document.querySelector('.modal-dialog') + if (dialog) { + await user.click(dialog) + expect(onClose).toHaveBeenCalled() + } + else { + throw new Error('Dialog root not found') + } + }) + + it('should call onClose when Escape key is pressed', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + + Content + , + ) + + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('should not call onClose when clicking inside the content', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + +
+ +
+
, + ) + + const innerButton = screen.getByRole('button', { name: 'Action' }) + await user.click(innerButton) + expect(onClose).not.toHaveBeenCalled() + + const contentPanel = document.querySelector('.bg-background-default-subtle') + await act(async () => { + fireEvent.click(contentPanel!) + }) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + describe('Default Props', () => { + it('should not throw if onClose is not provided', async () => { + const user = userEvent.setup() + render(Content) + + const closeButton = document.querySelector('.bg-components-button-tertiary-bg') + await user.click(closeButton!) + }) + }) +}) diff --git a/web/app/components/base/new-audio-button/index.spec.tsx b/web/app/components/base/new-audio-button/index.spec.tsx new file mode 100644 index 0000000000..a30b06535a --- /dev/null +++ b/web/app/components/base/new-audio-button/index.spec.tsx @@ -0,0 +1,205 @@ +import { act, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import i18next from 'i18next' +import { useParams, usePathname } from 'next/navigation' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import AudioBtn from './index' + +const mockPlayAudio = vi.fn() +const mockPauseAudio = vi.fn() +const mockGetAudioPlayer = vi.fn() + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(), + usePathname: vi.fn(), +})) + +vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({ + AudioPlayerManager: { + getInstance: vi.fn(() => ({ + getAudioPlayer: mockGetAudioPlayer, + })), + }, +})) + +describe('AudioBtn', () => { + const getButton = () => screen.getByRole('button') + + const hoverAndCheckTooltip = async (expectedText: string) => { + const button = getButton() + await userEvent.hover(button) + expect(await screen.findByText(expectedText)).toBeInTheDocument() + } + + const getAudioCallback = () => { + const lastCall = mockGetAudioPlayer.mock.calls[mockGetAudioPlayer.mock.calls.length - 1] + const callback = lastCall?.find((arg: unknown) => typeof arg === 'function') as ((event: string) => void) | undefined + if (!callback) + throw new Error('Audio callback not found - ensure mockGetAudioPlayer was called with a callback argument') + return callback + } + + beforeAll(() => { + i18next.init({}) + }) + + beforeEach(() => { + vi.clearAllMocks() + mockGetAudioPlayer.mockReturnValue({ + playAudio: mockPlayAudio, + pauseAudio: mockPauseAudio, + }) + ; (useParams as ReturnType).mockReturnValue({}) + ; (usePathname as ReturnType).mockReturnValue('/') + }) + + describe('URL Routing', () => { + it('should generate public URL when token is present', async () => { + ; (useParams as ReturnType).mockReturnValue({ token: 'test-token' }) + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/text-to-audio') + expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(true) + }) + + it('should generate app URL when appId is present', async () => { + ; (useParams as ReturnType).mockReturnValue({ appId: '123' }) + ; (usePathname as ReturnType).mockReturnValue('/apps/123/chat') + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/apps/123/text-to-audio') + expect(mockGetAudioPlayer.mock.calls[0][1]).toBe(false) + }) + + it('should generate installed app URL correctly', async () => { + ; (useParams as ReturnType).mockReturnValue({ appId: '456' }) + ; (usePathname as ReturnType).mockReturnValue('/explore/installed/app') + + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + expect(mockGetAudioPlayer.mock.calls[0][0]).toBe('/installed-apps/456/text-to-audio') + }) + }) + + describe('State Management', () => { + it('should start in initial state', async () => { + render() + + await hoverAndCheckTooltip('play') + expect(getButton()).toHaveClass('action-btn') + expect(getButton()).not.toBeDisabled() + }) + + it('should transition to playing state', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + + await hoverAndCheckTooltip('playing') + expect(getButton()).toHaveClass('action-btn-active') + }) + + it('should transition to ended state', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + act(() => { + getAudioCallback()('ended') + }) + + await hoverAndCheckTooltip('play') + expect(getButton()).not.toHaveClass('action-btn-active') + }) + + it('should handle paused event', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + act(() => { + getAudioCallback()('paused') + }) + + await hoverAndCheckTooltip('play') + }) + + it('should handle error event', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('error') + }) + + await hoverAndCheckTooltip('play') + }) + + it('should handle loaded event', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('loaded') + }) + + await hoverAndCheckTooltip('loading') + }) + }) + + describe('Play/Pause', () => { + it('should call playAudio when clicked', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockPlayAudio).toHaveBeenCalled()) + }) + + it('should call pauseAudio when clicked while playing', async () => { + render() + await userEvent.click(getButton()) + + act(() => { + getAudioCallback()('play') + }) + + await userEvent.click(getButton()) + await waitFor(() => expect(mockPauseAudio).toHaveBeenCalled()) + }) + + it('should disable button when loading', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(getButton()).toBeDisabled()) + }) + }) + + describe('Props', () => { + it('should pass props to audio player', async () => { + render() + await userEvent.click(getButton()) + + await waitFor(() => expect(mockGetAudioPlayer).toHaveBeenCalled()) + const call = mockGetAudioPlayer.mock.calls[0] + expect(call[2]).toBe('msg-1') + expect(call[3]).toBe('hello') + expect(call[4]).toBe('en-US') + }) + }) +}) diff --git a/web/app/components/base/notion-connector/index.spec.tsx b/web/app/components/base/notion-connector/index.spec.tsx new file mode 100644 index 0000000000..7ee799d002 --- /dev/null +++ b/web/app/components/base/notion-connector/index.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 NotionConnector from './index' + +describe('NotionConnector', () => { + it('should render the layout and actual sub-components (Icons & Button)', () => { + const { container } = render() + + // Verify Title & Tip translations + expect(screen.getByText('datasetCreation.stepOne.notionSyncTitle')).toBeInTheDocument() + expect(screen.getByText('datasetCreation.stepOne.notionSyncTip')).toBeInTheDocument() + + const notionWrapper = container.querySelector('.h-12.w-12') + const dotsWrapper = container.querySelector('.system-md-semibold') + + expect(notionWrapper?.querySelector('svg')).toBeInTheDocument() + expect(dotsWrapper?.querySelector('svg')).toBeInTheDocument() + + const button = screen.getByRole('button', { + name: /datasetcreation.stepone.connect/i, + }) + + expect(button).toBeInTheDocument() + expect(button).toHaveClass('btn', 'btn-primary') + }) + + it('should trigger the onSetting callback when the real button is clicked', async () => { + const onSetting = vi.fn() + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { + name: /datasetcreation.stepone.connect/i, + }) + + await user.click(button) + + expect(onSetting).toHaveBeenCalledTimes(1) + }) + + it('should maintain the correct visual hierarchy classes', () => { + const { container } = render() + + // Verify the outer container has the specific workflow-process-bg + const mainContainer = container.firstChild + expect(mainContainer).toHaveClass('bg-workflow-process-bg', 'rounded-2xl', 'p-6') + }) +}) diff --git a/web/app/components/base/skeleton/index.spec.tsx b/web/app/components/base/skeleton/index.spec.tsx new file mode 100644 index 0000000000..8f0d9a6837 --- /dev/null +++ b/web/app/components/base/skeleton/index.spec.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { + SkeletonContainer, + SkeletonPoint, + SkeletonRectangle, + SkeletonRow, +} from './index' + +describe('Skeleton Components', () => { + describe('Individual Components', () => { + it('should forward attributes and render children in SkeletonContainer', () => { + render( + + Content + , + ) + const element = screen.getByTestId('container') + expect(element).toHaveClass('flex', 'flex-col', 'custom-container') + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('should forward attributes and render children in SkeletonRow', () => { + render( + + Row Content + , + ) + const element = screen.getByTestId('row') + expect(element).toHaveClass('flex', 'items-center', 'custom-row') + expect(screen.getByText('Row Content')).toBeInTheDocument() + }) + + it('should apply base skeleton styles to SkeletonRectangle', () => { + render() + const element = screen.getByTestId('rect') + expect(element).toHaveClass('h-2', 'bg-text-quaternary', 'opacity-20', 'w-10') + }) + + it('should render the separator character correctly in SkeletonPoint', () => { + render() + const element = screen.getByTestId('point') + expect(element).toHaveTextContent('·') + expect(element).toHaveClass('text-text-quaternary') + }) + }) + + describe('Composition & Layout', () => { + it('should render a full skeleton structure accurately', () => { + const { container } = render( + + + + + + + , + ) + + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('main-wrapper') + + expect(container.querySelector('.rect-1')).toBeInTheDocument() + expect(container.querySelector('.rect-2')).toBeInTheDocument() + + const row = container.querySelector('.flex.items-center') + expect(row).toContainElement(container.querySelector('.rect-1') as HTMLElement) + expect(row).toHaveTextContent('·') + }) + }) + + it('should handle rest props like event listeners', async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render() + + const element = screen.getByTestId('clickable') + + await user.click(element) + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/base/slider/index.spec.tsx b/web/app/components/base/slider/index.spec.tsx new file mode 100644 index 0000000000..c9ebabd63e --- /dev/null +++ b/web/app/components/base/slider/index.spec.tsx @@ -0,0 +1,77 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import Slider from './index' + +describe('Slider Component', () => { + it('should render with correct default ARIA limits and current value', () => { + render() + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '0') + expect(slider).toHaveAttribute('aria-valuemax', '100') + expect(slider).toHaveAttribute('aria-valuenow', '50') + }) + + it('should apply custom min, max, and step values', () => { + render() + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuemin', '5') + expect(slider).toHaveAttribute('aria-valuemax', '20') + expect(slider).toHaveAttribute('aria-valuenow', '10') + }) + + it('should default to 0 if the value prop is NaN', () => { + render() + + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuenow', '0') + }) + + it('should call onChange when arrow keys are pressed', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render() + + const slider = screen.getByRole('slider') + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(21, 0) + }) + + it('should not trigger onChange when disabled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + + const slider = screen.getByRole('slider') + + expect(slider).toHaveAttribute('aria-disabled', 'true') + + await act(async () => { + slider.focus() + await user.keyboard('{ArrowRight}') + }) + + expect(onChange).not.toHaveBeenCalled() + }) + + it('should apply custom class names', () => { + render( + , + ) + + const sliderWrapper = screen.getByRole('slider').closest('.outer-test') + expect(sliderWrapper).toBeInTheDocument() + + const thumb = screen.getByRole('slider') + expect(thumb).toHaveClass('thumb-test') + }) +}) diff --git a/web/app/components/base/sort/index.spec.tsx b/web/app/components/base/sort/index.spec.tsx new file mode 100644 index 0000000000..92ea2b44f9 --- /dev/null +++ b/web/app/components/base/sort/index.spec.tsx @@ -0,0 +1,141 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import { describe, expect, it, vi } from 'vitest' +import Sort from './index' + +const mockItems = [ + { value: 'created_at', name: 'Date Created' }, + { value: 'name', name: 'Name' }, + { value: 'status', name: 'Status' }, +] + +describe('Sort component — real portal integration', () => { + const setup = (props = {}) => { + const onSelect = vi.fn() + const user = userEvent.setup() + const { container, rerender } = render( + , + ) + + // helper: returns a non-null HTMLElement or throws with a clear message + const getTriggerWrapper = (): HTMLElement => { + const labelNode = screen.getByText('appLog.filter.sortBy') + // try to find a reasonable wrapper element; prefer '.block' but fallback to any ancestor div + const wrapper = labelNode.closest('.block') ?? labelNode.closest('div') + if (!wrapper) + throw new Error('Trigger wrapper element not found for "Sort by" label') + return wrapper as HTMLElement + } + + // helper: returns right-side sort button element + const getSortButton = (): HTMLElement => { + const btn = container.querySelector('.rounded-r-lg') + if (!btn) + throw new Error('Sort button (rounded-r-lg) not found in rendered container') + return btn as HTMLElement + } + + return { user, onSelect, rerender, getTriggerWrapper, getSortButton } + } + + it('renders and shows selected item label and sort icon', () => { + const { getSortButton } = setup({ order: '' }) + + expect(screen.getByText('Date Created')).toBeInTheDocument() + + const sortButton = getSortButton() + expect(sortButton).toBeInstanceOf(HTMLElement) + expect(sortButton.querySelector('svg')).toBeInTheDocument() + }) + + it('opens and closes the tooltip (portal mounts to document.body)', async () => { + const { user, getTriggerWrapper } = setup() + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toBeInTheDocument() + expect(document.body.contains(tooltip)).toBe(true) + + // clicking the trigger again should close it + await user.click(getTriggerWrapper()) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('renders options and calls onSelect with descending prefix when order is "-"', async () => { + const { user, onSelect, getTriggerWrapper } = setup({ order: '-' }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + mockItems.forEach((item) => { + expect(within(tooltip).getByText(item.name)).toBeInTheDocument() + }) + + await user.click(within(tooltip).getByText('Name')) + expect(onSelect).toHaveBeenCalledWith('-name') + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) + + it('toggles sorting order: ascending -> descending via right-side button', async () => { + const { user, onSelect, getSortButton } = setup({ order: '', value: 'created_at' }) + await user.click(getSortButton()) + expect(onSelect).toHaveBeenCalledWith('-created_at') + }) + + it('toggles sorting order: descending -> ascending via right-side button', async () => { + const { user, onSelect, getSortButton } = setup({ order: '-', value: 'name' }) + await user.click(getSortButton()) + expect(onSelect).toHaveBeenCalledWith('name') + }) + + it('shows checkmark only for selected item in menu', async () => { + const { user, getTriggerWrapper } = setup({ value: 'status' }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + const statusRow = within(tooltip).getByText('Status').closest('.flex') + const nameRow = within(tooltip).getByText('Name').closest('.flex') + + if (!statusRow) + throw new Error('Status option row not found in menu') + if (!nameRow) + throw new Error('Name option row not found in menu') + + expect(statusRow.querySelector('svg')).toBeInTheDocument() + expect(nameRow.querySelector('svg')).not.toBeInTheDocument() + }) + + it('shows empty selection label when value is unknown', () => { + setup({ value: 'unknown_value' }) + const label = screen.getByText('appLog.filter.sortBy') + const valueNode = label.nextSibling + if (!valueNode) + throw new Error('Expected a sibling node for the selection text') + expect(String(valueNode.textContent || '').trim()).toBe('') + }) + + it('handles undefined order prop without asserting a literal "undefined" prefix', async () => { + const { user, onSelect, getTriggerWrapper } = setup({ order: undefined }) + + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + + await user.click(within(tooltip).getByText('Name')) + + expect(onSelect).toHaveBeenCalled() + expect(onSelect).toHaveBeenCalledWith(expect.stringMatching(/name$/)) + }) + + it('clicking outside the open menu closes the portal', async () => { + const { user, getTriggerWrapper } = setup() + await user.click(getTriggerWrapper()) + const tooltip = await screen.findByRole('tooltip') + expect(tooltip).toBeInTheDocument() + + // click outside: body click should close the tooltip + await user.click(document.body) + await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()) + }) +}) diff --git a/web/app/components/base/switch/index.spec.tsx b/web/app/components/base/switch/index.spec.tsx new file mode 100644 index 0000000000..b434ddd729 --- /dev/null +++ b/web/app/components/base/switch/index.spec.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import Switch from './index' + +describe('Switch', () => { + it('should render in unchecked state by default', () => { + render() + const switchElement = screen.getByRole('switch') + expect(switchElement).toBeInTheDocument() + expect(switchElement).toHaveAttribute('aria-checked', 'false') + }) + + it('should render in checked state when defaultValue is true', () => { + render() + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveAttribute('aria-checked', 'true') + }) + + it('should toggle state and call onChange when clicked', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render() + + const switchElement = screen.getByRole('switch') + + await user.click(switchElement) + expect(switchElement).toHaveAttribute('aria-checked', 'true') + expect(onChange).toHaveBeenCalledWith(true) + expect(onChange).toHaveBeenCalledTimes(1) + + await user.click(switchElement) + expect(switchElement).toHaveAttribute('aria-checked', 'false') + expect(onChange).toHaveBeenCalledWith(false) + expect(onChange).toHaveBeenCalledTimes(2) + }) + + it('should not call onChange when disabled', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render() + + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50') + + await user.click(switchElement) + expect(onChange).not.toHaveBeenCalled() + }) + + it('should apply correct size classes', () => { + const { rerender } = render() + // We only need to find the element once + const switchElement = screen.getByRole('switch') + expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-sm') + + rerender() + expect(switchElement).toHaveClass('h-3', 'w-5') + + rerender() + expect(switchElement).toHaveClass('h-4', 'w-7') + + rerender() + expect(switchElement).toHaveClass('h-5', 'w-9') + + rerender() + expect(switchElement).toHaveClass('h-6', 'w-11') + }) + + it('should apply custom className', () => { + render() + expect(screen.getByRole('switch')).toHaveClass('custom-test-class') + }) + + it('should apply correct background colors based on state', async () => { + const user = userEvent.setup() + render() + const switchElement = screen.getByRole('switch') + + expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked') + + await user.click(switchElement) + expect(switchElement).toHaveClass('bg-components-toggle-bg') + }) +}) diff --git a/web/app/components/base/tag/index.spec.tsx b/web/app/components/base/tag/index.spec.tsx new file mode 100644 index 0000000000..76d2915ba8 --- /dev/null +++ b/web/app/components/base/tag/index.spec.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Tag from './index' +import '@testing-library/jest-dom/vitest' + +describe('Tag Component', () => { + describe('Rendering', () => { + it('should render with text children', () => { + const { container } = render(Hello World) + expect(container.firstChild).toHaveTextContent('Hello World') + }) + + it('should render with ReactNode children', () => { + render(Node) + expect(screen.getByTestId('child')).toBeInTheDocument() + }) + + it('should always apply base layout classes', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass( + 'inline-flex', + 'shrink-0', + 'items-center', + 'rounded-md', + 'px-2.5', + 'py-px', + 'text-xs', + 'leading-5', + ) + }) + }) + + describe('Color Variants', () => { + it.each([ + { color: 'green', text: 'text-green-800', bg: 'bg-green-100' }, + { color: 'yellow', text: 'text-yellow-800', bg: 'bg-yellow-100' }, + { color: 'red', text: 'text-red-800', bg: 'bg-red-100' }, + { color: 'gray', text: 'text-gray-800', bg: 'bg-gray-100' }, + ])('should apply $color color classes', ({ color, text, bg }) => { + type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined + const { container } = render(Test) + expect(container.firstChild).toHaveClass(text, bg) + }) + + it('should default to green when no color specified', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('text-green-800', 'bg-green-100') + }) + + it('should not apply color classes for invalid color', () => { + type colorType = 'green' | 'yellow' | 'red' | 'gray' | undefined + const { container } = render(Test) + const className = (container.firstChild as HTMLElement)?.className || '' + + expect(className).not.toMatch(/text-(green|yellow|red|gray)-800/) + expect(className).not.toMatch(/bg-(green|yellow|red|gray)-100/) + }) + }) + + describe('Boolean Props', () => { + it('should apply border when bordered is true', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('border-[1px]') + }) + + it('should not apply border by default', () => { + const { container } = render(Test) + expect(container.firstChild).not.toHaveClass('border-[1px]') + }) + + it('should hide background when hideBg is true', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('bg-transparent') + }) + + it('should apply both bordered and hideBg together', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('border-[1px]', 'bg-transparent') + }) + + it('should override color background with hideBg', () => { + const { container } = render(Test) + const tag = container.firstChild + expect(tag).toHaveClass('bg-transparent', 'text-red-800') + }) + }) + + describe('Custom Styling', () => { + it('should merge custom className', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('my-custom-class') + }) + + it('should preserve base classes with custom className', () => { + const { container } = render(Test) + expect(container.firstChild).toHaveClass('inline-flex', 'my-custom-class') + }) + + it('should handle empty className prop', () => { + const { container } = render(Test) + expect(container.firstChild).toBeInTheDocument() + }) + }) +})