import React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithProviders, screen, fireEvent } from '../../test/utils'
import RichCard from '.'
const HERO = 'https://cdn.example.com/hero.jpg'
const IMG_2 = 'https://cdn.example.com/two.jpg'
const IMG_3 = 'https://cdn.example.com/three.jpg'
describe('RichCard', () => {
it('renders the title and subtitle', async () => {
renderWithProviders(
)
expect(await screen.findByTestId('rich-card')).toBeInTheDocument()
expect(screen.getByText('Set up your welcome message')).toBeInTheDocument()
expect(
screen.getByText('Automatically greet new followers.')
).toBeInTheDocument()
})
it('renders multiline bullet copy as separate lines', async () => {
renderWithProviders(
)
const card = await screen.findByTestId('rich-card')
expect(card).toHaveTextContent('š Say hello as people arrive')
expect(card).toHaveTextContent('⨠Spark conversations')
expect(card).toHaveTextContent('š¬ Add automated FAQs')
expect(card.querySelectorAll('br')).toHaveLength(2)
})
it('does not insert line breaks when the subtitle is clamped to one line', async () => {
renderWithProviders(
)
const card = await screen.findByTestId('rich-card')
expect(card.querySelectorAll('br')).toHaveLength(0)
expect(screen.getByText(/Say hello as people arrive/)).toHaveClass(
'truncate'
)
})
it('renders a single hero image for the hero presentation', async () => {
renderWithProviders(
)
await screen.findByTestId('rich-card')
const images = screen.getAllByRole('img')
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute('alt', 'Welcome')
})
it('renders up to three fanned images for the fan presentation', async () => {
const { container } = renderWithProviders(
)
await screen.findByTestId('rich-card')
// Three
nodes render; all are decorative (alt=""), so the fan is out
// of the a11y tree ā the accessible content is the card's title/subtitle.
expect(container.querySelectorAll('img')).toHaveLength(3)
expect(screen.queryByAltText('Collection')).toBeNull()
})
it('drops a failed fan image and re-fans the survivors', async () => {
const { container } = renderWithProviders(
)
await screen.findByTestId('rich-card')
const images = container.querySelectorAll('img')
expect(images).toHaveLength(3)
// A decorative tile 404s ā it's removed and the survivors re-fan.
fireEvent.error(images[2])
expect(container.querySelectorAll('img')).toHaveLength(2)
})
it('keeps fan thumbnails decorative (empty alt)', async () => {
const { container } = renderWithProviders(
)
await screen.findByTestId('rich-card')
const imgs = Array.from(container.querySelectorAll('img'))
expect(imgs).toHaveLength(3)
expect(imgs.every((img) => img.getAttribute('alt') === '')).toBe(true)
})
it('resets failed fan tiles when the image set changes', async () => {
const { container, rerender } = renderWithProviders(
)
await screen.findByTestId('rich-card')
fireEvent.error(container.querySelectorAll('img')[2])
expect(container.querySelectorAll('img')).toHaveLength(2)
// A recycled card with a new collection clears the stale failure tracking.
rerender(
)
expect(container.querySelectorAll('img')).toHaveLength(3)
})
it('defaults presentation from image count (>=2 fans)', async () => {
const { container } = renderWithProviders(
)
await screen.findByTestId('rich-card')
expect(container.querySelectorAll('img')).toHaveLength(2)
})
it('drops a failed hero image to the placeholder rather than a broken image', async () => {
const { container } = renderWithProviders(
)
const img = await screen.findByRole('img')
// The hero errors ā useChinPalette clears the URL ā CardThumbnail falls
// through to its type-icon placeholder, so no
remains.
fireEvent.error(img)
expect(container.querySelectorAll('img')).toHaveLength(0)
})
it('does not pin the card to a fixed height (grows for tall content)', async () => {
renderWithProviders(
)
const card = await screen.findByTestId('rich-card')
expect(card.className).not.toContain('h-[250px]')
})
it('renders no image region when there are no images', async () => {
renderWithProviders(
)
await screen.findByTestId('rich-card')
expect(screen.queryAllByRole('img')).toHaveLength(0)
expect(screen.getByText('Text only')).toBeInTheDocument()
})
it('renders a url action as an external anchor, others as buttons', async () => {
const onClick = vi.fn()
renderWithProviders(
)
// Secondary labels render inside a truncating span, so resolve the anchor/
// button from the label node.
const link = (await screen.findByText('Shop now')).closest('a')
expect(link).not.toBeNull()
expect(link).toHaveAttribute('href', 'https://tr.ee/x')
expect(link).toHaveAttribute('target', '_blank')
const button = screen.getByText('Set up').closest('button')
expect(button).not.toBeNull()
fireEvent.click(button as HTMLButtonElement)
expect(onClick).toHaveBeenCalledTimes(1)
})
it('stacks a primary footer full-width and lays a secondary footer inline', async () => {
const { rerender } = renderWithProviders(
)
// Primary ā stacked, full-width button.
expect(await screen.findByText('Set up welcome message')).toHaveClass(
'w-full'
)
rerender(
)
// Secondary ā inline, content-width button (no w-full).
expect(screen.getByText('Shop now').closest('a')).not.toHaveClass('w-full')
})
it('lays a secondary chin out as a row: copy left, action right', async () => {
const { rerender } = renderWithProviders(
)
// The action shares one justify-between row with the title/subtitle copy.
const link = await screen.findByText('Open')
const row = link.closest('.justify-between')
expect(row).not.toBeNull()
expect(row?.textContent).toContain('Test collection')
// Primary stays stacked below the copy ā no chin row.
rerender(
)
expect(screen.getByText('Set up').closest('.justify-between')).toBeNull()
})
})