import React from 'react' import { describe, expect, it, vi } from 'vitest' import { renderWithProviders, screen, fireEvent } from '../../test/utils' import { AUDIO_BG_CLASS } from './components/_shared/CardThumbnail' import LinkAttachment from '.' describe('LinkAttachment.Received (link previews)', () => { // MES-1349 dropped the hairline border and both shadow layers — neither // appears on any composer or sent instance in the Figma frames. What this // case actually defends is the navigation contract, so it now asserts only // that. it('renders a link preview as an external anchor when a URL is set', async () => { renderWithProviders( ) const root = await screen.findByTestId('link-attachment') expect(root.tagName).toBe('A') expect(root.getAttribute('href')).toBe('https://tr.ee/briemix') expect(root.getAttribute('target')).toBe('_blank') expect(root.getAttribute('rel')).toBe('noopener noreferrer') }) it('renders as a button when no URL is set and onClick is provided', async () => { const handleClick = vi.fn() renderWithProviders( ) const root = await screen.findByTestId('link-attachment') expect(root.tagName).toBe('BUTTON') fireEvent.click(root) expect(handleClick).toHaveBeenCalledTimes(1) }) it('routes the card-level onClick through the CTA when one is set', async () => { const onClick = vi.fn() const ctaOnClick = vi.fn() renderWithProviders( ) fireEvent.click(await screen.findByText('View FAQs')) expect(onClick).toHaveBeenCalledTimes(1) expect(ctaOnClick).toHaveBeenCalledTimes(1) }) it('truncates the description to a single line by default', async () => { renderWithProviders( ) const description = await screen.findByText( 'A longer body of prose that would wrap onto several lines' ) expect(description).toHaveClass('truncate') expect(description).not.toHaveStyle({ display: '-webkit-box' }) }) it('clamps the description to descriptionLines lines when greater than 1', async () => { renderWithProviders( ) const description = await screen.findByText( 'A longer body of prose that would wrap onto several lines' ) expect(description).not.toHaveClass('truncate') expect(description).toHaveStyle({ display: '-webkit-box', WebkitLineClamp: '3', }) }) it('renders the filled (primary) CTA when cta.variant is omitted', async () => { renderWithProviders( ) const button = await screen.findByText('Add now') expect(button.className).toContain('bg-[#121110]') expect(button.className).toContain('text-white') }) it('renders the subtle (secondary) CTA when cta.variant is "secondary"', async () => { renderWithProviders( ) const button = await screen.findByText('Add now') expect(button.className).toContain('bg-black/[0.06]') expect(button.className).toContain('text-[#121110]') }) }) describe('LinkAttachment.Sent', () => { it('renders the dark variant as an external link when url is set', async () => { const handleClick = vi.fn() renderWithProviders( ) const link = await screen.findByRole('link', { name: /My Playlist/i }) expect(link.tagName).toBe('A') expect(link.getAttribute('href')).toBe('https://tr.ee/briemix') expect(link.getAttribute('target')).toBe('_blank') expect(link.getAttribute('rel')).toBe('noopener noreferrer') fireEvent.click(link) expect(handleClick).toHaveBeenCalledTimes(1) }) it('stays non-interactive when no url is set', async () => { renderWithProviders( ) expect(await screen.findByText('My Playlist')).toBeInTheDocument() expect(screen.queryByRole('button')).toBeNull() expect(screen.queryByRole('link')).toBeNull() }) it('lets the CTA own navigation when one is set (shell stays non-interactive)', async () => { renderWithProviders( ) expect(await screen.findByText('FAQ')).toBeInTheDocument() const links = screen.getAllByRole('link') expect(links).toHaveLength(1) expect(links[0].getAttribute('href')).toBe('https://tr.ee/faq') expect(links[0]).toHaveTextContent('View FAQs') }) it('keeps a secondary CTA legible on an audio card (uses the painted light surface)', async () => { // A Sent audio card paints the light AUDIO_BG_CLASS even though it is a // "dark" card, so the secondary CTA must follow the actual surface (dark // text on light) rather than the nominal `dark` variant (white-on-white). renderWithProviders( ) const button = await screen.findByText('Add now') expect(button.className).toContain('bg-black/[0.06]') expect(button.className).toContain('text-[#121110]') expect(button.className).not.toContain('text-white') }) }) describe('LinkAttachment accent chin (MES-1349, MES-1388)', () => { const ACCENT = '#7008E7' // The near-white dominant colour from the MES-1388 report. Painted raw it put // white ink on 1.09:1; derived, it is legible on both sides. const PALE_ACCENT = '#FCF5E3' const CARD_PROPS = { title: 'Yoga on Ice with Brie Nala', url: 'tr.ee/briewebsite', thumbnailUrl: 'https://cdn.example.com/yoga.jpg', } // The colour derivation itself is CSS (`oklch(from …)` in styles.css) and is // not computed by jsdom, so these tests assert the contract React owns: the // raw extracted hex is published as `--accent` (and dropped when there is no // visible hero), leaving the surface derivation to the browser. const accentOf = (element: HTMLElement) => element.style.getPropertyValue('--accent') it('publishes the accent for the sender and inks the chin white', async () => { renderWithProviders( ) const root = await screen.findByRole('link') expect(accentOf(root)).toBe(ACCENT) // No inline background: the surface is derived from `--accent` in CSS, the // raw accent is never painted verbatim. expect(root.style.backgroundColor).toBe('') expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' }) }) it('publishes the accent for the receiver and inks the chin near-black', async () => { renderWithProviders( ) const root = await screen.findByRole('link') expect(accentOf(root)).toBe(ACCENT) expect(root.style.backgroundColor).toBe('') expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#040403' }) }) it('colours both sides from one extracted colour, never rejecting it', async () => { // The pale report colour and the vivid one both paint, on both sides — the // feature adds colour to every card with an image rather than half of them. for (const accentColor of [ACCENT, PALE_ACCENT]) { const sent = renderWithProviders( ) expect(accentOf(await screen.findByRole('link'))).toBe(accentColor) sent.unmount() const received = renderWithProviders( ) expect(accentOf(await screen.findByRole('link'))).toBe(accentColor) received.unmount() } }) it('falls back to a self-contained fill, not a consumer theme token', async () => { renderWithProviders() const root = await screen.findByRole('link') expect(accentOf(root)).toBe('') // The fill has to survive in consumers that do not share our Tailwind // theme. This package ships no utilities, so consumers generate our classes // against their own preset: `bg-shade` compiled here but was dropped in // frontyard (Arbor preset, no `shade` colour), leaving the chin transparent // under the own-message `color: white` (MES-1388). An arbitrary value is // generated from the class name alone, so assert that shape — not the // literal colour, which design may change. const fill = root.className .split(/\s+/) .find((c) => c.startsWith('bg-') && c !== 'bg-white') expect(fill).toBeDefined() expect(fill).toMatch(/^bg-\[/) }) it('treats an unparseable accent as no accent', async () => { renderWithProviders( ) const root = await screen.findByRole('link') expect(accentOf(root)).toBe('') // Ink still comes from the side — here the sender's fallback fill. expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' }) }) it('drops the accent when the hero image fails to load', async () => { renderWithProviders( ) const root = await screen.findByRole('link') expect(accentOf(root)).toBe(ACCENT) // A surface derived from an image the viewer cannot see is a lie, so the // card falls all the way back rather than keeping a colour with no source. fireEvent.error(screen.getByRole('img')) expect(accentOf(root)).toBe('') expect(screen.getByText(CARD_PROPS.title)).toHaveStyle({ color: '#FFFFFF' }) }) it('drops the accent when a playable video poster fails to load', () => { // A video's poster is its hero, but `