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() }) })