diff --git a/web/app/components/base/audio-gallery/index.spec.tsx b/web/app/components/base/audio-gallery/index.spec.tsx new file mode 100644 index 0000000000..9039d4995c --- /dev/null +++ b/web/app/components/base/audio-gallery/index.spec.tsx @@ -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
+ }, +})) + +describe('AudioGallery', () => { + afterEach(() => { + audioPlayerMock.mockClear() + vi.resetModules() + }) + + it('returns null when srcs array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryByTestId('audio-player')).toBeNull() + }) + + it('returns null when all srcs are falsy', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + expect(screen.queryByTestId('audio-player')).toBeNull() + }) + + it('filters out falsy srcs and passes valid srcs to AudioPlayer', () => { + render() + 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() + const root = container.firstChild as HTMLElement + expect(root).toBeTruthy() + expect(root.className).toContain('my-3') + }) +}) diff --git a/web/app/components/base/image-gallery/index.tsx b/web/app/components/base/image-gallery/index.tsx index 8ca8be0fc7..7d3ef77d28 100644 --- a/web/app/components/base/image-gallery/index.tsx +++ b/web/app/components/base/image-gallery/index.tsx @@ -47,6 +47,7 @@ const ImageGallery: FC = ({ style={imgStyle} src={src} alt="" + data-testid="gallery-image" // Added for testing onClick={() => setImagePreviewUrl(src)} onError={e => e.currentTarget.remove()} /> diff --git a/web/app/components/base/markdown-blocks/audio-block.spec.tsx b/web/app/components/base/markdown-blocks/audio-block.spec.tsx new file mode 100644 index 0000000000..166de39a16 --- /dev/null +++ b/web/app/components/base/markdown-blocks/audio-block.spec.tsx @@ -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
+ }, +})) // 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() + + 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() + + 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() + 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') + }) +}) diff --git a/web/app/components/base/markdown-blocks/button.spec.tsx b/web/app/components/base/markdown-blocks/button.spec.tsx new file mode 100644 index 0000000000..7a1b8e5827 --- /dev/null +++ b/web/app/components/base/markdown-blocks/button.spec.tsx @@ -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( + + } /> + , + ) + } + + 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') + }) +}) diff --git a/web/app/components/base/markdown-blocks/paragraph.spec.tsx b/web/app/components/base/markdown-blocks/paragraph.spec.tsx new file mode 100644 index 0000000000..1abfe246ba --- /dev/null +++ b/web/app/components/base/markdown-blocks/paragraph.spec.tsx @@ -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[] }) => ( +
{srcs.join(',')}
+ ), +})) + +type MockNode = { + children?: Array<{ + tagName?: string + properties?: { + src?: string + } + }> +} + +type ParagraphProps = { + node: MockNode + children?: React.ReactNode +} + +const renderParagraph = (props: ParagraphProps) => { + return render() +} + +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', Caption], + }) + + 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') + }) +}) diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.spec.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.spec.tsx new file mode 100644 index 0000000000..5479ab81ac --- /dev/null +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.spec.tsx @@ -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 }) => ( +
+ {url} + +
+ ), +})) + +/** + * 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) + }) + + it('should render a standard paragraph when not an image', () => { + const node: MockNode = { children: [{ tagName: 'span' }] } + render( + + Hello World + , + ) + + 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( + + + , + ) + + 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) + + vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:actual-blob-url') + + const { container } = render( + + + , + ) + + 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( + + + Caption Text + , + ) + + 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) + + const revokeSpy = vi.spyOn(globalThis.URL, 'revokeObjectURL') + vi.spyOn(globalThis.URL, 'createObjectURL').mockReturnValue('blob:cleanup-test') + + const { unmount } = render( + + + , + ) + + 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( + + + , + ) + + 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() + }) +}) diff --git a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx index 73189289f3..b9b4d5e873 100644 --- a/web/app/components/base/markdown-blocks/plugin-paragraph.tsx +++ b/web/app/components/base/markdown-blocks/plugin-paragraph.tsx @@ -58,13 +58,13 @@ export const PluginParagraph: React.FC = ({ pluginInfo, no const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined return ( -
+
{remainingChildren && ( -
{remainingChildren}
+
{remainingChildren}
)}
) } - return

{children}

+ return

{children}

} diff --git a/web/app/components/base/markdown-blocks/pre-code.spec.tsx b/web/app/components/base/markdown-blocks/pre-code.spec.tsx new file mode 100644 index 0000000000..a3cc234e8f --- /dev/null +++ b/web/app/components/base/markdown-blocks/pre-code.spec.tsx @@ -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( + + console.log("hello world") + , + ) + + 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( + + test content + , + ) + + const copySpan = container.querySelector('.copy-code-button') + expect(copySpan).toBeInTheDocument() + expect(copySpan?.tagName).toBe('SPAN') + }) + + it('renders as a
 element', () => {
+    const { container } = render(Content)
+    expect(container.querySelector('pre')).toBeInTheDocument()
+  })
+
+  it('handles multiple children correctly', () => {
+    render(
+      
+        Line 1
+        Line 2
+      ,
+    )
+
+    expect(screen.getByText('Line 1')).toBeInTheDocument()
+    expect(screen.getByText('Line 2')).toBeInTheDocument()
+  })
+
+  it('correctly instantiates the pre element node', () => {
+    const { container } = render(Ref check)
+    const pre = container.querySelector('pre')
+
+    // Verifies the node is an actual HTMLPreElement,
+    // confirming the ref-linked element rendered correctly.
+    expect(pre).toBeInstanceOf(HTMLPreElement)
+  })
+})
diff --git a/web/app/components/base/radio-card/index.spec.tsx b/web/app/components/base/radio-card/index.spec.tsx
new file mode 100644
index 0000000000..f1368476bf
--- /dev/null
+++ b/web/app/components/base/radio-card/index.spec.tsx
@@ -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(
+      ICON}
+        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(
+      i}
+        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(
+      i}
+        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(
+      i}
+        title="Chosen"
+        description="desc"
+        isChosen
+        chosenConfig={
config
} + />, + ) + + // 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( + i} + title="Custom" + description="desc" + className="my-root-class" + isChosen + chosenConfig={
cfg
} + 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( + i} + 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( + i} + title="Memo" + description="desc" + />, + ) + expect(screen.getByText('Memo')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/radio-card/simple/index.spec.tsx b/web/app/components/base/radio-card/simple/index.spec.tsx new file mode 100644 index 0000000000..42e03484e8 --- /dev/null +++ b/web/app/components/base/radio-card/simple/index.spec.tsx @@ -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( + , + ) + + expect(screen.getByText('Card Title')).toBeInTheDocument() + expect(screen.getByText('Card Description')).toBeInTheDocument() + }) + + it('renders JSX title correctly', () => { + render( + JSX Title} + description="Desc" + isChosen={false} + onChosen={vi.fn()} + />, + ) + + expect(screen.getByTestId('jsx-title')).toBeInTheDocument() + }) + + it('renders icon when provided', () => { + render( + ICON} + />, + ) + + expect(screen.getByTestId('icon')).toBeInTheDocument() + }) + + it('renders extra content when provided', () => { + render( + Extra Content
} + />, + ) + + expect(screen.getByTestId('extra')).toBeInTheDocument() + }) + + it('calls onChosen when clicked', async () => { + const user = userEvent.setup() + const onChosen = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('Clickable')) + expect(onChosen).toHaveBeenCalledTimes(1) + }) + + it('applies active class when isChosen is true', () => { + const { container: inactiveContainer } = render( + , + ) + const inactiveClassName = (inactiveContainer.firstChild as HTMLElement).className + + const { container: activeContainer } = render( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText('Memo')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/radio/component/group/index.spec.tsx b/web/app/components/base/radio/component/group/index.spec.tsx new file mode 100644 index 0000000000..e417c2a203 --- /dev/null +++ b/web/app/components/base/radio/component/group/index.spec.tsx @@ -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 ( +
+ {String(value)} + {showButton && ( + + )} +
+ ) +} + +describe('Group component', () => { + it('renders children and exposes provided value through context', () => { + render( + + + , + ) + + 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( + + + , + ) + + 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( + + + , + ) + + 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( + + {/* the consumer will call onChange which is undefined */} + + , + ) + + 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( + <> + + + + + + + , + ) + + const nodes = screen.getAllByTestId('radio-value') + // first should be "false", second "123" + expect(nodes[0]).toHaveTextContent('false') + expect(nodes[1]).toHaveTextContent('123') + }) +}) diff --git a/web/app/components/base/radio/component/radio/index.spec.tsx b/web/app/components/base/radio/component/radio/index.spec.tsx new file mode 100644 index 0000000000..cdf0587453 --- /dev/null +++ b/web/app/components/base/radio/component/radio/index.spec.tsx @@ -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(My Label) + + 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() + // there should be no