dify/web/app/components/explore/banner/banner-item.spec.tsx
Coding On Star 8f414af34e
test: add comprehensive tests (#31649)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 11:16:26 +08:00

484 lines
13 KiB
TypeScript

import type { Banner } from '@/models/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BannerItem } from './banner-item'
const mockScrollTo = vi.fn()
const mockSlideNodes = vi.fn()
vi.mock('@/app/components/base/carousel', () => ({
useCarousel: () => ({
api: {
scrollTo: mockScrollTo,
slideNodes: mockSlideNodes,
},
selectedIndex: 0,
}),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'banner.viewMore': 'View More',
}
return translations[key] || key
},
}),
}))
const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
id: 'banner-1',
status: 'enabled',
link: 'https://example.com',
content: {
'category': 'Featured',
'title': 'Test Banner Title',
'description': 'Test banner description text',
'img-src': 'https://example.com/image.png',
},
...overrides,
} as Banner)
// Mock ResizeObserver methods declared at module level and initialized
const mockResizeObserverObserve = vi.fn()
const mockResizeObserverDisconnect = vi.fn()
// Create mock class outside of describe block for proper hoisting
class MockResizeObserver {
constructor(_callback: ResizeObserverCallback) {
// Store callback if needed
}
observe(...args: Parameters<ResizeObserver['observe']>) {
mockResizeObserverObserve(...args)
}
disconnect() {
mockResizeObserverDisconnect()
}
unobserve() {
// No-op
}
}
describe('BannerItem', () => {
let mockWindowOpen: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
mockSlideNodes.mockReturnValue([{}, {}, {}]) // 3 slides
vi.stubGlobal('ResizeObserver', MockResizeObserver)
// Mock window.innerWidth for responsive tests
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1400, // Above RESPONSIVE_BREAKPOINT (1200)
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.unstubAllGlobals()
mockWindowOpen.mockRestore()
})
describe('basic rendering', () => {
it('renders banner category', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Featured')).toBeInTheDocument()
})
it('renders banner title', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('renders banner description', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Test banner description text')).toBeInTheDocument()
})
it('renders banner image with correct src and alt', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const image = screen.getByRole('img')
expect(image).toHaveAttribute('src', 'https://example.com/image.png')
expect(image).toHaveAttribute('alt', 'Test Banner Title')
})
it('renders view more text', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('View More')).toBeInTheDocument()
})
})
describe('click handling', () => {
it('opens banner link in new tab when clicked', () => {
const banner = createMockBanner({ link: 'https://test-link.com' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://test-link.com',
'_blank',
'noopener,noreferrer',
)
})
it('does not open window when banner has no link', () => {
const banner = createMockBanner({ link: '' })
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
})
describe('slide indicators', () => {
it('renders correct number of indicator buttons', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
})
it('renders indicator buttons with correct numbers', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('01')).toBeInTheDocument()
expect(screen.getByText('02')).toBeInTheDocument()
expect(screen.getByText('03')).toBeInTheDocument()
})
it('calls scrollTo when indicator is clicked', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const secondIndicator = screen.getByText('02').closest('button')
fireEvent.click(secondIndicator!)
expect(mockScrollTo).toHaveBeenCalledWith(1)
})
it('renders no indicators when no slides', () => {
mockSlideNodes.mockReturnValue([])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
describe('isPaused prop', () => {
it('defaults isPaused to false', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// Component should render without issues
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('accepts isPaused prop', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
isPaused={true}
/>,
)
// Component should render with isPaused
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
})
describe('responsive behavior', () => {
it('sets up ResizeObserver on mount', () => {
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(mockResizeObserverObserve).toHaveBeenCalled()
})
it('adds resize event listener on mount', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
addEventListenerSpy.mockRestore()
})
it('removes resize event listener on unmount', () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const banner = createMockBanner()
const { unmount } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
removeEventListenerSpy.mockRestore()
})
it('sets maxWidth when window width is below breakpoint', () => {
// Set window width below RESPONSIVE_BREAKPOINT (1200)
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 1000,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// Component should render and apply responsive styles
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
})
it('applies responsive styles when below breakpoint', () => {
// Set window width below RESPONSIVE_BREAKPOINT (1200)
Object.defineProperty(window, 'innerWidth', {
writable: true,
configurable: true,
value: 800,
})
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// The component should render even with responsive mode
expect(screen.getByText('View More')).toBeInTheDocument()
})
})
describe('content variations', () => {
it('renders long category text', () => {
const banner = createMockBanner({
content: {
'category': 'Very Long Category Name',
'title': 'Title',
'description': 'Description',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
})
it('renders long title with truncation class', () => {
const banner = createMockBanner({
content: {
'category': 'Category',
'title': 'A Very Long Title That Should Be Truncated Eventually',
'description': 'Description',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
expect(titleElement).toHaveClass('line-clamp-2')
})
it('renders long description with truncation class', () => {
const banner = createMockBanner({
content: {
'category': 'Category',
'title': 'Title',
'description': 'A very long description that should be limited to a certain number of lines for proper display in the banner component.',
'img-src': 'https://example.com/img.png',
},
} as Partial<Banner>)
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const descriptionElement = screen.getByText(/A very long description/)
expect(descriptionElement).toHaveClass('line-clamp-4')
})
})
describe('slide calculation', () => {
it('calculates next index correctly for first slide', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
// With selectedIndex=0 and 3 slides, nextIndex should be 1
// The second indicator button should show the "next slide" state
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(3)
})
it('handles single slide case', () => {
mockSlideNodes.mockReturnValue([{}])
const banner = createMockBanner()
render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
})
})
describe('wrapper styling', () => {
it('has cursor-pointer class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('cursor-pointer')
})
it('has rounded-2xl class', () => {
const banner = createMockBanner()
const { container } = render(
<BannerItem
banner={banner}
autoplayDelay={5000}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('rounded-2xl')
})
})
})