test: add unit tests for base-components-part-2 (#32409)

This commit is contained in:
Poojan
2026-02-24 12:04:48 +05:30
committed by GitHub
parent f923901d3f
commit 657eeb65b8
15 changed files with 1253 additions and 3 deletions

View File

@@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// AudioGallery.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import AudioGallery from './index'
// Mock AudioPlayer so we only assert prop forwarding
const audioPlayerMock = vi.fn()
vi.mock('./AudioPlayer', () => ({
default: (props: { srcs: string[] }) => {
audioPlayerMock(props)
return <div data-testid="audio-player" />
},
}))
describe('AudioGallery', () => {
afterEach(() => {
audioPlayerMock.mockClear()
vi.resetModules()
})
it('returns null when srcs array is empty', () => {
const { container } = render(<AudioGallery srcs={[]} />)
expect(container.firstChild).toBeNull()
expect(screen.queryByTestId('audio-player')).toBeNull()
})
it('returns null when all srcs are falsy', () => {
const { container } = render(<AudioGallery srcs={['', '', '']} />)
expect(container.firstChild).toBeNull()
expect(screen.queryByTestId('audio-player')).toBeNull()
})
it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => {
render(<AudioGallery srcs={['a.mp3', '', 'b.mp3']} />)
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['a.mp3', 'b.mp3'] })
})
it('wraps AudioPlayer inside container with expected class', () => {
const { container } = render(<AudioGallery srcs={['a.mp3']} />)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
expect(root.className).toContain('my-3')
})
})

View File

@@ -47,6 +47,7 @@ const ImageGallery: FC<Props> = ({
style={imgStyle}
src={src}
alt=""
data-testid="gallery-image" // Added for testing
onClick={() => setImagePreviewUrl(src)}
onError={e => e.currentTarget.remove()}
/>

View File

@@ -0,0 +1,73 @@
import type { NamedExoticComponent } from 'react'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
// AudioBlock.integration.spec.tsx
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AudioBlock from './audio-block'
// Mock the nested AudioPlayer used by AudioGallery (do not mock AudioGallery itself)
const audioPlayerMock = vi.fn()
vi.mock('@/app/components/base/audio-gallery/AudioPlayer', () => ({
default: (props: { srcs: string[] }) => {
audioPlayerMock(props)
return <div data-testid="audio-player" data-srcs={JSON.stringify(props.srcs)} />
},
})) // adjust path if AudioBlock sits elsewhere
describe('AudioBlock (integration - real AudioGallery)', () => {
beforeEach(() => {
audioPlayerMock.mockClear()
})
it('renders AudioGallery with multiple srcs extracted from node.children', () => {
const node = {
children: [
{ properties: { src: 'one.mp3' } },
{ properties: { src: 'two.mp3' } },
{ type: 'text', value: 'plain' },
],
properties: {},
}
const { container } = render(<AudioBlock node={node} />)
const gallery = screen.getByTestId('audio-player')
expect(gallery).toBeInTheDocument()
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['one.mp3', 'two.mp3'] })
expect(container.firstChild).not.toBeNull()
})
it('renders AudioGallery with single src from node.properties when no children with properties', () => {
const node = {
children: [{ type: 'text', value: 'no-src' }],
properties: { src: 'single.mp3' },
}
render(<AudioBlock node={node} />)
expect(audioPlayerMock).toHaveBeenCalledTimes(1)
expect(audioPlayerMock).toHaveBeenCalledWith({ srcs: ['single.mp3'] })
expect(screen.getByTestId('audio-player')).toBeInTheDocument()
})
it('returns null when there are no audio sources', () => {
const node = {
children: [{ type: 'text', value: 'nothing here' }],
properties: {},
}
const { container } = render(<AudioBlock node={node} />)
expect(container.firstChild).toBeNull()
expect(audioPlayerMock).not.toHaveBeenCalled()
})
it('has displayName set to AudioBlock', () => {
const component = AudioBlock as NamedExoticComponent<{ node: unknown }>
expect(component.displayName).toBe('AudioBlock')
})
})

View File

@@ -0,0 +1,121 @@
import type { NamedExoticComponent } from 'react'
import type { ChatContextValue } from '@/app/components/base/chat/chat/context'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// markdown-button.spec.tsx
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
import MarkdownButton from './button'
// Only mock the URL utility so behavior is deterministic
const isValidUrlSpy = vi.fn()
vi.mock('./utils', () => ({
isValidUrl: (u: string) => isValidUrlSpy(u),
})) // test subject
type TestNode = {
properties?: {
dataVariant?: string
dataMessage?: string
dataLink?: string
dataSize?: string
}
children?: Array<{ value?: string }>
}
describe('MarkdownButton (integration)', () => {
const onSendSpy = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
function renderWithCtx(node: TestNode) {
// Provide minimal ChatContext; cast to ChatContextValue to satisfy the provider signature
const ctx = {
onSend: (msg: unknown) => onSendSpy(msg),
// other props are optional at runtime; assert type to satisfy TS
} as unknown as ChatContextValue
return render(
<ChatContextProvider {...ctx}>
<MarkdownButton node={node as unknown as Record<string, unknown>} />
</ChatContextProvider>,
)
}
it('renders button text from node children', () => {
const node: TestNode = { children: [{ value: 'Click me' }], properties: {} }
renderWithCtx(node)
expect(screen.getByRole('button')).toHaveTextContent('Click me')
})
it('opens new tab when link is valid and does not call onSend', async () => {
isValidUrlSpy.mockReturnValue(true)
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
const user = userEvent.setup()
const node: TestNode = {
properties: { dataLink: 'https://example.com' },
children: [{ value: 'Go' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).toHaveBeenCalledWith('https://example.com')
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank')
expect(onSendSpy).not.toHaveBeenCalled()
openSpy.mockRestore()
})
it('calls onSend when link is invalid but message exists', async () => {
isValidUrlSpy.mockReturnValue(false)
const user = userEvent.setup()
const node: TestNode = {
properties: { dataLink: 'not-a-url', dataMessage: 'hello!' },
children: [{ value: 'Send' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).toHaveBeenCalledWith('not-a-url')
expect(onSendSpy).toHaveBeenCalledTimes(1)
expect(onSendSpy).toHaveBeenCalledWith('hello!')
})
it('does nothing when no link and no message', async () => {
isValidUrlSpy.mockReturnValue(false)
const user = userEvent.setup()
const node: TestNode = { properties: {}, children: [{ value: 'Empty' }] }
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(isValidUrlSpy).not.toHaveBeenCalled()
expect(onSendSpy).not.toHaveBeenCalled()
})
it('calls onSend when message present and no link', async () => {
const user = userEvent.setup()
const node: TestNode = {
properties: { dataMessage: 'msg-only' },
children: [{ value: 'Msg' }],
}
renderWithCtx(node)
await user.click(screen.getByRole('button'))
expect(onSendSpy).toHaveBeenCalledWith('msg-only')
})
it('has displayName set to MarkdownButton', () => {
const comp = MarkdownButton as NamedExoticComponent<{ node: unknown }>
expect(comp.displayName).toBe('MarkdownButton')
})
})

View File

@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Paragraph from './paragraph'
vi.mock('@/app/components/base/image-gallery', () => ({
default: ({ srcs }: { srcs: string[] }) => (
<div data-testid="image-gallery">{srcs.join(',')}</div>
),
}))
type MockNode = {
children?: Array<{
tagName?: string
properties?: {
src?: string
}
}>
}
type ParagraphProps = {
node: MockNode
children?: React.ReactNode
}
const renderParagraph = (props: ParagraphProps) => {
return render(<Paragraph {...props} />)
}
describe('Paragraph', () => {
it('should render normal paragraph when no image child exists', () => {
renderParagraph({
node: { children: [] },
children: 'Hello world',
})
expect(screen.getByText('Hello world').tagName).toBe('P')
})
it('should render image gallery when first child is img', () => {
renderParagraph({
node: {
children: [
{
tagName: 'img',
properties: { src: 'test.png' },
},
],
},
children: ['Image only'],
})
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
expect(screen.getByTestId('image-gallery')).toHaveTextContent('test.png')
})
it('should render additional content after image when children length > 1', () => {
renderParagraph({
node: {
children: [
{
tagName: 'img',
properties: { src: 'test.png' },
},
],
},
children: ['Image', <span key="1">Caption</span>],
})
expect(screen.getByTestId('image-gallery')).toBeInTheDocument()
expect(screen.getByText('Caption')).toBeInTheDocument()
})
it('should render paragraph when first child exists but is not img', () => {
renderParagraph({
node: {
children: [
{
tagName: 'div',
},
],
},
children: 'Not image',
})
expect(screen.getByText('Not image').tagName).toBe('P')
})
it('should render paragraph when children_node is undefined', () => {
renderParagraph({
node: {},
children: 'Fallback',
})
expect(screen.getByText('Fallback').tagName).toBe('P')
})
})

View File

@@ -0,0 +1,181 @@
/* eslint-disable next/no-img-element */
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginReadmeAsset } from '@/service/use-plugins'
import { PluginParagraph } from './plugin-paragraph'
import { getMarkdownImageURL } from './utils'
// Mock dependencies
vi.mock('@/service/use-plugins', () => ({
usePluginReadmeAsset: vi.fn(),
}))
vi.mock('./utils', () => ({
getMarkdownImageURL: vi.fn(),
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
<div data-testid="image-preview-modal">
<span>{url}</span>
<button onClick={onCancel} type="button">Close</button>
</div>
),
}))
/**
* Interfaces to avoid 'any' and satisfy strict linting
*/
type MockNode = {
children?: Array<{
tagName?: string
properties?: { src?: string }
}>
}
type HookReturn = {
data?: Blob
isLoading?: boolean
error?: Error | null
}
describe('PluginParagraph', () => {
const mockPluginInfo = {
pluginUniqueIdentifier: 'test-plugin-id',
pluginId: 'plugin-123',
}
beforeEach(() => {
vi.clearAllMocks()
// Ensure URL globals exist in the test environment using globalThis
if (!globalThis.URL.createObjectURL) {
globalThis.URL.createObjectURL = vi.fn()
globalThis.URL.revokeObjectURL = vi.fn()
}
// Default mock return to prevent destructuring errors
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: undefined,
isLoading: false,
error: null,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
})
it('should render a standard paragraph when not an image', () => {
const node: MockNode = { children: [{ tagName: 'span' }] }
render(
<PluginParagraph node={node}>
Hello World
</PluginParagraph>,
)
expect(screen.getByTestId('standard-paragraph')).toHaveTextContent('Hello World')
})
it('should render an ImageGallery when the first child is an image', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/test-img.png')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
expect(screen.getByTestId('image-paragraph-wrapper')).toBeInTheDocument()
// Query by selector since alt="" removes the 'img' role from the accessibility tree
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'https://cdn.com/test-img.png')
})
it('should use a blob URL when asset data is successfully fetched', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:actual-blob-url')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
const img = container.querySelector('img')
expect(img).toHaveAttribute('src', 'blob:actual-blob-url')
})
it('should render remaining children below the image gallery', () => {
const node: MockNode = {
children: [
{ tagName: 'img', properties: { src: 'test-img.png' } },
{ tagName: 'text' },
],
}
render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
<span>Caption Text</span>
</PluginParagraph>,
)
expect(screen.getByTestId('remaining-children')).toHaveTextContent('Caption Text')
})
it('should revoke the blob URL on unmount to prevent memory leaks', () => {
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
const mockBlob = new Blob([''], { type: 'image/png' })
vi.mocked(usePluginReadmeAsset).mockReturnValue({
data: mockBlob,
} as HookReturn as ReturnType<typeof usePluginReadmeAsset>)
const revokeSpy = vi.spyOn(globalThis.URL, 'revokeObjectURL')
vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:cleanup-test')
const { unmount } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
unmount()
expect(revokeSpy).toHaveBeenCalledWith('blob:cleanup-test')
})
it('should open the image preview modal when an image in the gallery is clicked', async () => {
const user = userEvent.setup()
const node: MockNode = {
children: [{ tagName: 'img', properties: { src: 'test-img.png' } }],
}
vi.mocked(getMarkdownImageURL).mockReturnValue('https://cdn.com/gallery.png')
const { container } = render(
<PluginParagraph pluginInfo={mockPluginInfo} node={node}>
<img src="test-img.png" alt="" />
</PluginParagraph>,
)
const img = container.querySelector('img')
if (img)
await user.click(img)
// ImageGallery is not mocked, so it should trigger the preview
expect(screen.getByTestId('image-preview-modal')).toBeInTheDocument()
expect(screen.getByText('https://cdn.com/gallery.png')).toBeInTheDocument()
const closeBtn = screen.getByText('Close')
await user.click(closeBtn)
expect(screen.queryByTestId('image-preview-modal')).not.toBeInTheDocument()
})
})

View File

@@ -58,13 +58,13 @@ export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, no
const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined
return (
<div className="markdown-img-wrapper">
<div className="markdown-img-wrapper" data-testid="image-paragraph-wrapper">
<ImageGallery srcs={[imageUrl]} />
{remainingChildren && (
<div className="mt-2">{remainingChildren}</div>
<div className="mt-2" data-testid="remaining-children">{remainingChildren}</div>
)}
</div>
)
}
return <p>{children}</p>
return <p data-testid="standard-paragraph">{children}</p>
}

View File

@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it } from 'vitest'
import PreCode from './pre-code'
describe('PreCode Component', () => {
it('renders children correctly inside the pre tag', () => {
const { container } = render(
<PreCode>
<code data-testid="test-code">console.log("hello world")</code>
</PreCode>,
)
const preElement = container.querySelector('pre')
const codeElement = screen.getByTestId('test-code')
expect(preElement).toBeInTheDocument()
expect(codeElement).toBeInTheDocument()
// Verify code is a descendant of pre
expect(preElement).toContainElement(codeElement)
expect(codeElement.textContent).toBe('console.log("hello world")')
})
it('contains the copy button span for CSS targeting', () => {
const { container } = render(
<PreCode>
<code>test content</code>
</PreCode>,
)
const copySpan = container.querySelector('.copy-code-button')
expect(copySpan).toBeInTheDocument()
expect(copySpan?.tagName).toBe('SPAN')
})
it('renders as a <pre> element', () => {
const { container } = render(<PreCode>Content</PreCode>)
expect(container.querySelector('pre')).toBeInTheDocument()
})
it('handles multiple children correctly', () => {
render(
<PreCode>
<span>Line 1</span>
<span>Line 2</span>
</PreCode>,
)
expect(screen.getByText('Line 1')).toBeInTheDocument()
expect(screen.getByText('Line 2')).toBeInTheDocument()
})
it('correctly instantiates the pre element node', () => {
const { container } = render(<PreCode>Ref check</PreCode>)
const pre = container.querySelector('pre')
// Verifies the node is an actual HTMLPreElement,
// confirming the ref-linked element rendered correctly.
expect(pre).toBeInstanceOf(HTMLPreElement)
})
})

View File

@@ -0,0 +1,137 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioCard from './index'
describe('RadioCard', () => {
it('renders icon, title and description', () => {
render(
<RadioCard
icon={<span data-testid="icon">ICON</span>}
title="Card Title"
description="Some description"
/>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Some description')).toBeInTheDocument()
})
it('calls onChosen when clicked', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
render(
<RadioCard
icon={<span>i</span>}
title="Clickable"
description="desc"
onChosen={onChosen}
/>,
)
await user.click(screen.getByText('Clickable'))
expect(onChosen).toHaveBeenCalledTimes(1)
})
it('hides radio element when noRadio is true and still shows chosen-config area (wrapper)', () => {
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="No Radio"
description="desc"
noRadio
/>,
)
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
expect(radioWrapper).toBeNull()
// chosen-config area should appear because noRadio true triggers the block
const chosenArea = container.querySelector('.mt-2')
expect(chosenArea).toBeTruthy()
})
it('shows radio checked styles when isChosen and shows chosenConfig', () => {
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="Chosen"
description="desc"
isChosen
chosenConfig={<div data-testid="chosen-config">config</div>}
/>,
)
// radio absolute wrapper exists
const radioWrapper = container.querySelector('.absolute.right-3.top-3')
expect(radioWrapper).toBeTruthy()
// inner circle div should have checked fragment in class list
const inner = radioWrapper?.querySelector('div')
expect(inner).toBeTruthy()
expect(inner?.className).toContain('border-components-radio-border-checked')
// chosenConfig rendered
expect(screen.getByTestId('chosen-config')).toBeInTheDocument()
})
it('applies custom className to root and merges chosenConfigWrapClassName', () => {
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="Custom"
description="desc"
className="my-root-class"
isChosen
chosenConfig={<div>cfg</div>}
chosenConfigWrapClassName="my-config-wrap"
/>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
expect(root.className).toContain('my-root-class')
expect(root.className).toContain('border-[1.5px]')
expect(root.className).toContain('bg-components-option-card-option-selected-bg')
const chosenWrap = container.querySelector('.mt-2 .my-config-wrap')
expect(chosenWrap).toBeTruthy()
expect(chosenWrap?.textContent).toBe('cfg')
})
it('does not render radio when noRadio true and still allows clicking on whole card', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
const { container } = render(
<RadioCard
icon={<span>i</span>}
title="ClickNoRadio"
description="desc"
noRadio
onChosen={onChosen}
/>,
)
// click title should trigger onChosen
await user.click(screen.getByText('ClickNoRadio'))
expect(onChosen).toHaveBeenCalledTimes(1)
// radio area should be absent
expect(container.querySelector('.absolute.right-3.top-3')).toBeNull()
})
it('memo export renders correctly', () => {
render(
<RadioCard
icon={<span>i</span>}
title="Memo"
description="desc"
/>,
)
expect(screen.getByText('Memo')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,137 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioCard from './index'
describe('RadioCard', () => {
it('renders title and description', () => {
render(
<RadioCard
title="Card Title"
description="Card Description"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByText('Card Description')).toBeInTheDocument()
})
it('renders JSX title correctly', () => {
render(
<RadioCard
title={<span data-testid="jsx-title">JSX Title</span>}
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByTestId('jsx-title')).toBeInTheDocument()
})
it('renders icon when provided', () => {
render(
<RadioCard
title="With Icon"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
icon={<span data-testid="icon">ICON</span>}
/>,
)
expect(screen.getByTestId('icon')).toBeInTheDocument()
})
it('renders extra content when provided', () => {
render(
<RadioCard
title="With Extra"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
extra={<div data-testid="extra">Extra Content</div>}
/>,
)
expect(screen.getByTestId('extra')).toBeInTheDocument()
})
it('calls onChosen when clicked', async () => {
const user = userEvent.setup()
const onChosen = vi.fn()
render(
<RadioCard
title="Clickable"
description="Desc"
isChosen={false}
onChosen={onChosen}
/>,
)
await user.click(screen.getByText('Clickable'))
expect(onChosen).toHaveBeenCalledTimes(1)
})
it('applies active class when isChosen is true', () => {
const { container: inactiveContainer } = render(
<RadioCard
title="Inactive"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
const inactiveClassName = (inactiveContainer.firstChild as HTMLElement).className
const { container: activeContainer } = render(
<RadioCard
title="Active"
description="Desc"
isChosen
onChosen={vi.fn()}
/>,
)
const activeRoot = activeContainer.firstChild as HTMLElement
expect(activeRoot.className).not.toBe(inactiveClassName)
// Since it uses CSS modules, we expect the active class to be appended or changed
// In index.tsx it's cn(s.item, isChosen && s.active)
expect(activeRoot.className.length).toBeGreaterThan(inactiveClassName.length)
expect(activeRoot.className).toContain(inactiveClassName)
})
it('does not apply active styling logic when isChosen is false', () => {
const { container } = render(
<RadioCard
title="Inactive"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
// It should have some classes but not the active one
expect(root.className).not.toBe('')
expect(root.className).not.toContain('active') // CSS modules usually append _active
})
it('memo export renders correctly', () => {
render(
<RadioCard
title="Memo"
description="Desc"
isChosen={false}
onChosen={vi.fn()}
/>,
)
expect(screen.getByText('Memo')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useContextSelector } from 'use-context-selector'
// Group.test.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioGroupContext from '../../context'
import Group from './index'
// small consumer that uses the same context as your component
function ContextConsumer({ showButton = true }: { showButton?: boolean }) {
// eslint-disable-next-line ts/no-explicit-any
const ctx = useContextSelector(RadioGroupContext, (v: any) => v)
const value = ctx?.value
const onChange = ctx?.onChange
return (
<div>
<span data-testid="radio-value">{String(value)}</span>
{showButton && (
<button
data-testid="radio-change-btn"
onClick={() => onChange?.('clicked-from-test')}
>
change
</button>
)}
</div>
)
}
describe('Group component', () => {
it('renders children and exposes provided value through context', () => {
render(
<Group value="initial-value">
<ContextConsumer />
</Group>,
)
const valueNode = screen.getByTestId('radio-value')
expect(valueNode).toBeInTheDocument()
expect(valueNode).toHaveTextContent('initial-value')
})
it('merges custom className with existing classes on root element', () => {
const { container } = render(
<Group value="v" className="my-extra-class">
<ContextConsumer />
</Group>,
)
const root = container.firstChild as HTMLElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('my-extra-class')
// ensure it still has other classes (from cn + css module)
expect(root.className.length).toBeGreaterThan('my-extra-class'.length)
})
it('calls onChange from context when consumer triggers it', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(
<Group value="whatever" onChange={handleChange}>
<ContextConsumer />
</Group>,
)
const btn = screen.getByTestId('radio-change-btn')
await user.click(btn)
expect(handleChange).toHaveBeenCalledTimes(1)
expect(handleChange).toHaveBeenCalledWith('clicked-from-test')
})
it('does not throw if onChange is not provided and consumer calls it', async () => {
const user = userEvent.setup()
render(
<Group value={0}>
{/* the consumer will call onChange which is undefined */}
<ContextConsumer />
</Group>,
)
const btn = screen.getByTestId('radio-change-btn')
// clicking should not throw (if it threw the test would fail)
await user.click(btn)
// value still rendered correctly (verifies consumer reads numeric/false-y values too)
expect(screen.getByTestId('radio-value')).toHaveTextContent('0')
})
it('correctly passes boolean and numeric values through context', () => {
render(
<>
<Group value={false}>
<ContextConsumer />
</Group>
<Group value={123}>
<ContextConsumer showButton={false} />
</Group>
</>,
)
const nodes = screen.getAllByTestId('radio-value')
// first should be "false", second "123"
expect(nodes[0]).toHaveTextContent('false')
expect(nodes[1]).toHaveTextContent('123')
})
})

View File

@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioGroupContext from '../../context'
import Radio from './index'
describe('Radio component', () => {
it('renders label children and assigns an id to the label', () => {
const { container } = render(<Radio>My Label</Radio>)
const label = screen.getByText('My Label')
expect(label).toBeInTheDocument()
// label must be an HTMLLabelElement with an id assigned by useId
expect(label.tagName.toLowerCase()).toBe('label')
expect(label).toHaveAttribute('id')
const root = container.firstChild as HTMLElement
expect(root).toBeTruthy()
})
it('does not render a label when children is falsey', () => {
render(<Radio />)
// there should be no <label> in the document
const labels = screen.queryAllByRole('label')
expect(labels.length).toBe(0)
// also ensure no textual children
expect(screen.queryByText(/./)).toBeNull()
})
it('calls both local onChange and group onChange when clicked', async () => {
const user = userEvent.setup()
const localChange = vi.fn()
const groupChange = vi.fn()
render(
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
<Radio value="v1" onChange={localChange}>
ClickMe
</Radio>
</RadioGroupContext.Provider>,
)
const root = screen.getByText('ClickMe').closest('div') as HTMLElement
await user.click(root)
expect(localChange).toHaveBeenCalledTimes(1)
expect(localChange).toHaveBeenCalledWith('v1')
expect(groupChange).toHaveBeenCalledTimes(1)
expect(groupChange).toHaveBeenCalledWith('v1')
})
it('does not call onChange handlers when disabled', async () => {
const user = userEvent.setup()
const localChange = vi.fn()
const groupChange = vi.fn()
render(
<RadioGroupContext.Provider value={{ value: null, onChange: groupChange }}>
<Radio value="v2" onChange={localChange} disabled>
DisabledLabel
</Radio>
</RadioGroupContext.Provider>,
)
const root = screen.getByText('DisabledLabel').closest('div') as HTMLElement
await user.click(root)
expect(localChange).not.toHaveBeenCalled()
expect(groupChange).not.toHaveBeenCalled()
})
it('uses group value to determine checked state and applies checked class fragment', () => {
const { container: c1 } = render(
<RadioGroupContext.Provider value={{ value: 'yes', onChange: () => {} }}>
<Radio value="yes">CheckedByGroup</Radio>
</RadioGroupContext.Provider>,
)
const root1 = c1.firstChild as HTMLElement
expect(root1).toBeTruthy()
// component conditionally adds the 'bg-components-option-card-option-bg-hover' fragment when checked
expect(root1.className).toContain('bg-components-option-card-option-bg-hover')
const { container: c2 } = render(<Radio checked>CheckedByProp</Radio>)
const root2 = c2.firstChild as HTMLElement
expect(root2).toBeTruthy()
expect(root2.className).toContain('bg-components-option-card-option-bg-hover')
})
it('merges custom className with component classes', () => {
const { container } = render(<Radio className="my-custom-class">Label</Radio>)
const root = container.firstChild as HTMLElement
expect(root).toBeInTheDocument()
expect(root.className).toContain('my-custom-class')
// ensure other classes still exist (merged)
expect(root.className.length).toBeGreaterThan('my-custom-class'.length)
})
})

View File

@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react'
import { useContextSelector } from 'use-context-selector'
// context.spec.tsx
import { describe, expect, it } from 'vitest'
import RadioGroupContext from './index'
function Consumer() {
const value = useContextSelector(RadioGroupContext, v => v)
return <div data-testid="ctx-value">{JSON.stringify(value)}</div>
}
describe('RadioGroupContext', () => {
it('provides null as default value when no provider is used', () => {
render(<Consumer />)
const node = screen.getByTestId('ctx-value')
expect(node).toBeInTheDocument()
expect(node).toHaveTextContent('null')
})
it('provides value from provider when wrapped', () => {
const providedValue = { value: 'radio', onChange: () => {} }
render(
<RadioGroupContext.Provider value={providedValue}>
<Consumer />
</RadioGroupContext.Provider>,
)
const node = screen.getByTestId('ctx-value')
expect(node).toBeInTheDocument()
expect(node).toHaveTextContent(JSON.stringify(providedValue))
})
it('updates when provider value changes', () => {
const first = { value: 'first', onChange: () => {} }
const second = { value: 'second', onChange: () => {} }
const { rerender } = render(
<RadioGroupContext.Provider value={first}>
<Consumer />
</RadioGroupContext.Provider>,
)
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
JSON.stringify(first),
)
rerender(
<RadioGroupContext.Provider value={second}>
<Consumer />
</RadioGroupContext.Provider>,
)
expect(screen.getByTestId('ctx-value')).toHaveTextContent(
JSON.stringify(second),
)
})
})

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// index.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import Group from './component/group'
import Radio from './index'
describe('Radio (index)', () => {
it('attaches Group as a property on the default export', () => {
expect(Radio.Group).toBe(Group)
})
it('renders Radio when used as a component', () => {
render(<Radio>RootLabel</Radio>)
expect(screen.getByText('RootLabel')).toBeInTheDocument()
const label = screen.getByText('RootLabel')
expect(label.tagName.toLowerCase()).toBe('label')
})
it('Radio.Group provides context to nested Radio and group onChange is called on click', async () => {
const user = userEvent.setup()
const groupOnChange = vi.fn()
render(
<Radio.Group value="val" onChange={groupOnChange}>
<Radio value="val">InnerRadio</Radio>
</Radio.Group>,
)
const root = screen.getByText('InnerRadio').closest('div') as HTMLElement
await user.click(root)
expect(groupOnChange).toHaveBeenCalledTimes(1)
expect(groupOnChange).toHaveBeenCalledWith('val')
})
it('Radio.Group can render arbitrary children', () => {
render(
<Radio.Group value={undefined} onChange={() => {}}>
<div data-testid="plain-child">child</div>
</Radio.Group>,
)
expect(screen.getByTestId('plain-child')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,88 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// radio-ui.spec.tsx
import { describe, expect, it, vi } from 'vitest'
import RadioUI from './ui'
describe('RadioUI component', () => {
it('renders with correct role and aria attributes', () => {
render(<RadioUI isChecked />)
const radio = screen.getByRole('radio')
expect(radio).toBeInTheDocument()
expect(radio).toHaveAttribute('aria-checked', 'true')
expect(radio).toHaveAttribute('aria-disabled', 'false')
})
it('applies checked + enabled styles', () => {
render(<RadioUI isChecked />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('border-[5px]')
expect(radio.className).toContain('border-components-radio-border-checked')
})
it('applies unchecked + enabled styles', () => {
render(<RadioUI isChecked={false} />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('border-components-radio-border')
})
it('applies checked + disabled styles', () => {
render(<RadioUI isChecked disabled />)
const radio = screen.getByRole('radio')
expect(radio).toHaveAttribute('aria-disabled', 'true')
expect(radio.className).toContain(
'border-components-radio-border-checked-disabled',
)
})
it('applies unchecked + disabled styles', () => {
render(<RadioUI isChecked={false} disabled />)
const radio = screen.getByRole('radio')
expect(radio.className).toContain(
'border-components-radio-border-disabled',
)
expect(radio.className).toContain(
'bg-components-radio-bg-disabled',
)
})
it('calls onCheck when clicked if not disabled', async () => {
const user = userEvent.setup()
const handleCheck = vi.fn()
render(<RadioUI isChecked={false} onCheck={handleCheck} />)
const radio = screen.getByRole('radio')
await user.click(radio)
expect(handleCheck).toHaveBeenCalledTimes(1)
})
it('does not call onCheck when disabled', async () => {
const user = userEvent.setup()
const handleCheck = vi.fn()
render(
<RadioUI isChecked={false} disabled onCheck={handleCheck} />,
)
const radio = screen.getByRole('radio')
await user.click(radio)
expect(handleCheck).not.toHaveBeenCalled()
})
it('merges custom className', () => {
render(
<RadioUI isChecked={false} className="my-extra-class" />,
)
const radio = screen.getByRole('radio')
expect(radio.className).toContain('my-extra-class')
})
it('memo export renders correctly', () => {
render(<RadioUI isChecked />)
expect(screen.getByRole('radio')).toBeInTheDocument()
})
})