mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08:00
test: add tests for some base components (#32356)
This commit is contained in:
218
web/app/components/base/carousel/index.spec.tsx
Normal file
218
web/app/components/base/carousel/index.spec.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Carousel, useCarousel } from './index'
|
||||
|
||||
vi.mock('embla-carousel-react', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
type EmblaEventName = 'reInit' | 'select'
|
||||
type EmblaListener = (api: MockEmblaApi | undefined) => void
|
||||
|
||||
type MockEmblaApi = {
|
||||
scrollPrev: Mock
|
||||
scrollNext: Mock
|
||||
scrollTo: Mock
|
||||
selectedScrollSnap: Mock
|
||||
canScrollPrev: Mock
|
||||
canScrollNext: Mock
|
||||
slideNodes: Mock
|
||||
on: Mock
|
||||
off: Mock
|
||||
}
|
||||
|
||||
let mockCanScrollPrev = false
|
||||
let mockCanScrollNext = false
|
||||
let mockSelectedIndex = 0
|
||||
let mockSlideCount = 3
|
||||
let listeners: Record<EmblaEventName, EmblaListener[]>
|
||||
let mockApi: MockEmblaApi
|
||||
const mockCarouselRef = vi.fn()
|
||||
|
||||
const mockedUseEmblaCarousel = vi.mocked(useEmblaCarousel)
|
||||
|
||||
const createMockEmblaApi = (): MockEmblaApi => ({
|
||||
scrollPrev: vi.fn(),
|
||||
scrollNext: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
selectedScrollSnap: vi.fn(() => mockSelectedIndex),
|
||||
canScrollPrev: vi.fn(() => mockCanScrollPrev),
|
||||
canScrollNext: vi.fn(() => mockCanScrollNext),
|
||||
slideNodes: vi.fn(() =>
|
||||
Array.from({ length: mockSlideCount }, () => document.createElement('div')),
|
||||
),
|
||||
on: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event].push(callback)
|
||||
}),
|
||||
off: vi.fn((event: EmblaEventName, callback: EmblaListener) => {
|
||||
listeners[event] = listeners[event].filter(listener => listener !== callback)
|
||||
}),
|
||||
})
|
||||
|
||||
const emitEmblaEvent = (event: EmblaEventName, api: MockEmblaApi | undefined = mockApi) => {
|
||||
listeners[event].forEach(callback => callback(api))
|
||||
}
|
||||
|
||||
const renderCarouselWithControls = (orientation: 'horizontal' | 'vertical' = 'horizontal') => {
|
||||
return render(
|
||||
<Carousel orientation={orientation}>
|
||||
<Carousel.Content data-testid="carousel-content">
|
||||
<Carousel.Item>Slide 1</Carousel.Item>
|
||||
</Carousel.Content>
|
||||
<Carousel.Previous>Prev</Carousel.Previous>
|
||||
<Carousel.Next>Next</Carousel.Next>
|
||||
<Carousel.Dot>Dot</Carousel.Dot>
|
||||
</Carousel>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Carousel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCanScrollPrev = false
|
||||
mockCanScrollNext = false
|
||||
mockSelectedIndex = 0
|
||||
mockSlideCount = 3
|
||||
listeners = { reInit: [], select: [] }
|
||||
mockApi = createMockEmblaApi()
|
||||
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, mockApi] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
})
|
||||
|
||||
// Rendering and basic semantic structure.
|
||||
describe('Rendering', () => {
|
||||
it('should render region and slides when used with content and items', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('region')).toHaveAttribute('aria-roledescription', 'carousel')
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex')
|
||||
expect(screen.getByRole('group')).toHaveAttribute('aria-roledescription', 'slide')
|
||||
})
|
||||
})
|
||||
|
||||
// Props should be translated into Embla options and visible layout.
|
||||
describe('Props', () => {
|
||||
it('should configure embla with horizontal axis when orientation is omitted', () => {
|
||||
render(
|
||||
<Carousel opts={{ loop: true }} plugins={['plugin-marker' as unknown as never]}>
|
||||
<Carousel.Content />
|
||||
</Carousel>,
|
||||
)
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ loop: true, axis: 'x' },
|
||||
['plugin-marker'],
|
||||
)
|
||||
})
|
||||
|
||||
it('should configure embla with vertical axis and vertical content classes when orientation is vertical', () => {
|
||||
renderCarouselWithControls('vertical')
|
||||
|
||||
expect(mockedUseEmblaCarousel).toHaveBeenCalledWith(
|
||||
{ axis: 'y' },
|
||||
undefined,
|
||||
)
|
||||
expect(screen.getByTestId('carousel-content')).toHaveClass('flex-col')
|
||||
})
|
||||
})
|
||||
|
||||
// Users can move slides through previous and next controls.
|
||||
describe('User interactions', () => {
|
||||
it('should call scroll handlers when previous and next buttons are clicked', () => {
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Prev' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
||||
|
||||
expect(mockApi.scrollPrev).toHaveBeenCalledTimes(1)
|
||||
expect(mockApi.scrollNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call scrollTo with clicked index when a dot is clicked', () => {
|
||||
renderCarouselWithControls()
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
|
||||
fireEvent.click(dots[2])
|
||||
|
||||
expect(mockApi.scrollTo).toHaveBeenCalledWith(2)
|
||||
})
|
||||
})
|
||||
|
||||
// Embla events should keep control states and selected index in sync.
|
||||
describe('State synchronization', () => {
|
||||
it('should update disabled states and active dot when select event is emitted', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
mockCanScrollPrev = true
|
||||
mockCanScrollNext = true
|
||||
mockSelectedIndex = 2
|
||||
|
||||
act(() => {
|
||||
emitEmblaEvent('select')
|
||||
})
|
||||
|
||||
const dots = screen.getAllByRole('button', { name: 'Dot' })
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled()
|
||||
expect(dots[2]).toHaveAttribute('data-state', 'active')
|
||||
})
|
||||
|
||||
it('should subscribe to embla events and unsubscribe from select on unmount', () => {
|
||||
const { unmount } = renderCarouselWithControls()
|
||||
|
||||
const selectCallback = mockApi.on.mock.calls.find(
|
||||
call => call[0] === 'select',
|
||||
)?.[1] as EmblaListener
|
||||
|
||||
expect(mockApi.on).toHaveBeenCalledWith('reInit', expect.any(Function))
|
||||
expect(mockApi.on).toHaveBeenCalledWith('select', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockApi.off).toHaveBeenCalledWith('select', selectCallback)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge-case behavior for missing providers or missing embla api values.
|
||||
describe('Edge cases', () => {
|
||||
it('should throw when useCarousel is used outside Carousel provider', () => {
|
||||
const InvalidConsumer = () => {
|
||||
useCarousel()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<InvalidConsumer />)).toThrowError(
|
||||
'useCarousel must be used within a <Carousel />',
|
||||
)
|
||||
})
|
||||
|
||||
it('should render with disabled controls and no dots when embla api is undefined', () => {
|
||||
mockedUseEmblaCarousel.mockReturnValue(
|
||||
[mockCarouselRef, undefined] as unknown as ReturnType<typeof useEmblaCarousel>,
|
||||
)
|
||||
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Dot' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore select callback when embla emits an undefined api', () => {
|
||||
renderCarouselWithControls()
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
emitEmblaEvent('select', undefined)
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user