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 `