import { render, screen, fireEvent } from '@testing-library/react'
import { MetricCard } from '../MetricCard'
import { PipelineFunnel } from '../PipelineFunnel'
import { TopCampaigns } from '../TopCampaigns'
import { SparklineTrend } from '../SparklineTrend'
import { PeriodSelector } from '../PeriodSelector'
import { DashboardGrid } from '../DashboardGrid'
import { DashboardSection } from '../DashboardSection'
// ---------------------------------------------------------------------------
// Shared test helpers
// ---------------------------------------------------------------------------
function MockIcon({ className }: { className?: string }) {
return
}
// ---------------------------------------------------------------------------
// MetricCard
// ---------------------------------------------------------------------------
describe('MetricCard', () => {
const baseProps = {
label: 'Total Leads',
value: 1234,
icon: MockIcon,
}
it('renders the label and value', () => {
render()
expect(screen.getByText('Total Leads')).toBeInTheDocument()
expect(screen.getByText('1234')).toBeInTheDocument()
})
it('renders string values', () => {
render()
expect(screen.getByText('$9,999')).toBeInTheDocument()
})
it('renders the icon', () => {
render()
expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
})
it('renders "—" while loading', () => {
render()
expect(screen.getByText('—')).toBeInTheDocument()
expect(screen.queryByText('1234')).not.toBeInTheDocument()
})
it('renders "Error" in error state', () => {
render()
expect(screen.getByText('Error')).toBeInTheDocument()
})
it('applies animate-pulse class when loading', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('animate-pulse')
})
it('applies error border classes when error is true', () => {
const { container } = render()
// The inner content div gets the error classes
const cardDiv = container.querySelector('.border-red-200')
expect(cardDiv).toBeInTheDocument()
})
describe('trend indicator', () => {
it('renders positive trend value with + prefix', () => {
render()
expect(screen.getByText('+12%')).toBeInTheDocument()
})
it('renders negative trend value without + prefix', () => {
render()
expect(screen.getByText('-5%')).toBeInTheDocument()
})
it('renders neutral trend when isPositive is undefined', () => {
render()
expect(screen.getByText('0%')).toBeInTheDocument()
})
it('renders positive trend in green', () => {
render()
const trendEl = screen.getByText('+8%').closest('div')
expect(trendEl).toHaveClass('text-green-600')
})
it('renders negative trend in red', () => {
render()
const trendEl = screen.getByText('-3%').closest('div')
expect(trendEl).toHaveClass('text-red-600')
})
it('renders neutral trend in gray', () => {
render()
const trendEl = screen.getByText('0%').closest('div')
expect(trendEl).toHaveClass('text-gray-500')
})
it('renders the trend label', () => {
render(
)
expect(screen.getByText('vs last month')).toBeInTheDocument()
})
it('hides trend while loading', () => {
render(
)
expect(screen.queryByText('+5%')).not.toBeInTheDocument()
})
it('hides trend in error state', () => {
render(
)
expect(screen.queryByText('+5%')).not.toBeInTheDocument()
})
})
describe('sparkline', () => {
it('renders sparkline when sparklineData has 2+ points', () => {
const { container } = render(
)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('does not render sparkline for a single data point', () => {
const { container } = render(
)
// The only svg present might be the icon; sparkline itself returns null for < 2 points
const svgs = container.querySelectorAll('svg')
// icon is an svg; sparkline should not add a second one
expect(svgs.length).toBe(1)
})
it('does not render sparkline while loading', () => {
const { container } = render(
)
// Only the mock icon svg should exist (no sparkline)
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBe(1)
})
})
describe('href link', () => {
it('wraps content in an anchor tag when href is provided', () => {
render()
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/leads')
})
it('sets accessible label on the anchor', () => {
render()
expect(screen.getByRole('link', { name: 'View Total Leads details' })).toBeInTheDocument()
})
it('does not render anchor when href is omitted', () => {
render()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
})
it('applies custom className', () => {
const { container } = render()
const cardDiv = container.querySelector('.custom-class')
expect(cardDiv).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// SparklineTrend
// ---------------------------------------------------------------------------
describe('SparklineTrend', () => {
it('renders an svg with the correct role and aria-label for increasing trend', () => {
render()
const svg = screen.getByRole('img')
expect(svg).toHaveAttribute('aria-label', 'Trend chart showing increasing pattern')
})
it('reports decreasing trend in aria-label', () => {
render()
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'Trend chart showing decreasing pattern'
)
})
it('reports stable trend in aria-label', () => {
render()
expect(screen.getByRole('img')).toHaveAttribute(
'aria-label',
'Trend chart showing stable pattern'
)
})
it('returns null for a single data point', () => {
const { container } = render()
expect(container.firstChild).toBeNull()
})
it('returns null for an empty array', () => {
const { container } = render()
expect(container.firstChild).toBeNull()
})
it('renders both the line path and the area fill path', () => {
const { container } = render()
const paths = container.querySelectorAll('path')
// area fill + line stroke = 2 paths
expect(paths.length).toBe(2)
})
it('accepts a hex color directly', () => {
const { container } = render()
const linePath = container.querySelectorAll('path')[1]
expect(linePath).toHaveAttribute('stroke', '#FF0000')
})
it('resolves a Tailwind color class to hex', () => {
const { container } = render()
const linePath = container.querySelectorAll('path')[1]
expect(linePath).toHaveAttribute('stroke', '#10B981')
})
it('falls back to blue-500 for unknown color keys', () => {
const { container } = render()
const linePath = container.querySelectorAll('path')[1]
expect(linePath).toHaveAttribute('stroke', '#3B82F6')
})
it('applies custom height', () => {
const { container } = render()
const svg = container.querySelector('svg')
expect(svg).toHaveAttribute('height', '60')
})
it('applies custom className', () => {
const { container } = render(
)
expect(container.querySelector('svg')).toHaveClass('my-sparkline')
})
})
// ---------------------------------------------------------------------------
// PeriodSelector
// ---------------------------------------------------------------------------
describe('PeriodSelector', () => {
const onChange = jest.fn()
beforeEach(() => jest.clearAllMocks())
it('renders default period buttons', () => {
render()
expect(screen.getByText('Week')).toBeInTheDocument()
expect(screen.getByText('Month')).toBeInTheDocument()
expect(screen.getByText('Quarter')).toBeInTheDocument()
expect(screen.getByText('Year')).toBeInTheDocument()
})
it('marks the selected period button as pressed', () => {
render()
expect(screen.getByText('Quarter')).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('Week')).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByText('Month')).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByText('Year')).toHaveAttribute('aria-pressed', 'false')
})
it('calls onChange with the correct value on button click', () => {
render()
fireEvent.click(screen.getByText('Year'))
expect(onChange).toHaveBeenCalledWith('year')
expect(onChange).toHaveBeenCalledTimes(1)
})
it('calls onChange when the already-selected period is clicked', () => {
render()
fireEvent.click(screen.getByText('Month'))
expect(onChange).toHaveBeenCalledWith('month')
})
it('renders custom periods', () => {
const custom = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
]
render()
expect(screen.getByText('Day')).toBeInTheDocument()
expect(screen.getByText('Week')).toBeInTheDocument()
expect(screen.queryByText('Month')).not.toBeInTheDocument()
})
it('has role="group" with accessible label', () => {
const { container } = render()
const group = container.querySelector('[role="group"]')
expect(group).toBeInTheDocument()
expect(group).toHaveAttribute('aria-label', 'Time period')
})
it('applies custom className to the wrapper', () => {
const { container } = render(
)
expect(container.firstChild).toHaveClass('my-selector')
})
it('applies active style class only to selected button', () => {
render()
const weekBtn = screen.getByText('Week')
const monthBtn = screen.getByText('Month')
expect(weekBtn).toHaveClass('bg-primary/15')
expect(monthBtn).not.toHaveClass('bg-primary/15')
})
})
// ---------------------------------------------------------------------------
// PipelineFunnel
// ---------------------------------------------------------------------------
describe('PipelineFunnel', () => {
const stages = [
{ stage: 'Lead', count: 200 },
{ stage: 'Qualified', count: 100 },
{ stage: 'Proposal', count: 50 },
{ stage: 'Closed', count: 20 },
]
it('renders the default title', () => {
render()
expect(screen.getByText('Pipeline Funnel')).toBeInTheDocument()
})
it('renders a custom title', () => {
render()
expect(screen.getByText('Sales Funnel')).toBeInTheDocument()
})
it('renders all stage names', () => {
render()
expect(screen.getByText('Lead')).toBeInTheDocument()
expect(screen.getByText('Qualified')).toBeInTheDocument()
expect(screen.getByText('Proposal')).toBeInTheDocument()
expect(screen.getByText('Closed')).toBeInTheDocument()
})
it('renders stage counts with locale formatting', () => {
const bigStages = [
{ stage: 'Top', count: 1000 },
{ stage: 'Mid', count: 500 },
]
render()
expect(screen.getByText('1,000')).toBeInTheDocument()
expect(screen.getByText('500')).toBeInTheDocument()
})
it('shows "% of total" relative to the first stage', () => {
render()
// First stage = 200, second = 100 → 50%, third = 50 → 25%, last = 20 → 10%
expect(screen.getByText('100% of total')).toBeInTheDocument()
expect(screen.getByText('50% of total')).toBeInTheDocument()
expect(screen.getByText('25% of total')).toBeInTheDocument()
expect(screen.getByText('10% of total')).toBeInTheDocument()
})
it('renders the overall conversion footer when there are 2+ stages', () => {
render()
expect(screen.getByText('Overall Conversion')).toBeInTheDocument()
// The footer span contains "10%" directly followed by the route span "(Lead → Closed)"
// Use getAllByText and check one matches the bold footer element specifically
const tenPctMatches = screen.getAllByText(/10%/)
// At least one match must be the bold footer span (not the "% of total" span)
const footerMatch = tenPctMatches.find(
el => !el.textContent?.includes('of total')
)
expect(footerMatch).toBeTruthy()
expect(screen.getByText(/Lead → Closed/)).toBeInTheDocument()
})
it('does not render overall conversion footer with a single stage', () => {
render()
expect(screen.queryByText('Overall Conversion')).not.toBeInTheDocument()
})
it('renders empty state when stages is an empty array', () => {
render()
expect(screen.getByText('No pipeline data available')).toBeInTheDocument()
})
it('renders empty state title even in empty state', () => {
render()
expect(screen.getByText('My Funnel')).toBeInTheDocument()
})
it('renders stage list with role="list"', () => {
render()
expect(screen.getByRole('list', { name: 'Pipeline stages' })).toBeInTheDocument()
})
it('renders each stage as a listitem', () => {
render()
expect(screen.getAllByRole('listitem')).toHaveLength(stages.length)
})
it('renders bar buttons with accessible labels', () => {
render()
const leadBtn = screen.getByRole('button', {
name: 'Lead: 200 prospects, 100% of total',
})
expect(leadBtn).toBeInTheDocument()
})
it('calls onStageClick with stage name when a bar is clicked', () => {
const onStageClick = jest.fn()
render()
// Click the listitem div wrapping the 'Qualified' stage
const listitems = screen.getAllByRole('listitem')
fireEvent.click(listitems[1])
expect(onStageClick).toHaveBeenCalledWith('Qualified')
expect(onStageClick).toHaveBeenCalledTimes(1)
})
it('disables bar buttons when onStageClick is not provided', () => {
render()
const buttons = screen.getAllByRole('button')
buttons.forEach(btn => expect(btn).toBeDisabled())
})
it('enables bar buttons when onStageClick is provided', () => {
render()
const buttons = screen.getAllByRole('button')
buttons.forEach(btn => expect(btn).not.toBeDisabled())
})
it('handles stages with zero counts without crashing', () => {
const zeroStages = [
{ stage: 'Lead', count: 0 },
{ stage: 'Closed', count: 0 },
]
render()
expect(screen.getByText('Lead')).toBeInTheDocument()
expect(screen.getByText('Overall Conversion')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// TopCampaigns
// ---------------------------------------------------------------------------
describe('TopCampaigns', () => {
const campaigns = [
{ id: '1', name: 'Spring Launch', engagementRate: 80, status: 'active' as const },
{ id: '2', name: 'Summer Sale', engagementRate: 55, status: 'paused' as const },
{ id: '3', name: 'Fall Promo', engagementRate: 30, status: 'completed' as const },
{ id: '4', name: 'Winter Deal', engagementRate: 15 },
{ id: '5', name: 'Holiday Push', engagementRate: 70, status: 'active' as const },
{ id: '6', name: 'Extra Campaign', engagementRate: 40, status: 'active' as const },
]
it('renders the default title', () => {
render()
expect(screen.getByText('Top Performing Campaigns')).toBeInTheDocument()
})
it('renders a custom title', () => {
render()
expect(screen.getByText('Best Campaigns')).toBeInTheDocument()
})
it('shows empty state when campaigns array is empty', () => {
render()
expect(screen.getByText('No campaigns yet')).toBeInTheDocument()
expect(
screen.getByText('Launch your first campaign to see performance metrics')
).toBeInTheDocument()
})
it('renders empty state title in empty state', () => {
render()
expect(screen.getByText('My Campaigns')).toBeInTheDocument()
})
it('limits display to maxItems (default 5)', () => {
render()
// 6 campaigns but only 5 should appear
expect(screen.queryByText('Extra Campaign')).not.toBeInTheDocument()
expect(screen.getByText('Spring Launch')).toBeInTheDocument()
expect(screen.getByText('Holiday Push')).toBeInTheDocument()
})
it('respects a custom maxItems value', () => {
render()
expect(screen.getByText('Spring Launch')).toBeInTheDocument()
expect(screen.getByText('Summer Sale')).toBeInTheDocument()
expect(screen.queryByText('Fall Promo')).not.toBeInTheDocument()
})
it('renders rank numbers starting at 1', () => {
render()
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByText('3')).toBeInTheDocument()
})
it('renders engagement rates as percentages', () => {
render()
expect(screen.getByText('80%')).toBeInTheDocument()
expect(screen.getByText('55%')).toBeInTheDocument()
})
it('renders progress bars with correct aria attributes', () => {
render()
const progressBars = screen.getAllByRole('progressbar')
expect(progressBars[0]).toHaveAttribute('aria-valuenow', '80')
expect(progressBars[0]).toHaveAttribute('aria-valuemin', '0')
expect(progressBars[0]).toHaveAttribute('aria-valuemax', '100')
expect(progressBars[0]).toHaveAttribute(
'aria-label',
'Spring Launch engagement rate: 80%'
)
})
it('renders campaign status badges', () => {
render()
// Two campaigns have 'active' status within the first 5 (Spring Launch + Holiday Push)
const activebadges = screen.getAllByText('active')
expect(activebadges.length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('paused')).toBeInTheDocument()
expect(screen.getByText('completed')).toBeInTheDocument()
})
it('does not render a status badge when status is omitted', () => {
const noBadge = [{ id: '1', name: 'No Status', engagementRate: 50 }]
render()
// Only one listitem, no status span expected
expect(screen.queryByText('active')).not.toBeInTheDocument()
expect(screen.queryByText('paused')).not.toBeInTheDocument()
})
it('renders the list with role and aria-label', () => {
render()
expect(
screen.getByRole('list', { name: 'Top campaigns by engagement' })
).toBeInTheDocument()
})
it('renders each campaign as a listitem', () => {
render()
// maxItems defaults to 5
expect(screen.getAllByRole('listitem')).toHaveLength(5)
})
it('shows average engagement footer', () => {
const simple = [
{ id: '1', name: 'A', engagementRate: 40 },
{ id: '2', name: 'B', engagementRate: 60 },
]
render()
expect(screen.getByText('Average Engagement')).toBeInTheDocument()
expect(screen.getByText('50%')).toBeInTheDocument()
})
describe('View all button', () => {
it('shows "View all" when onViewAll is provided AND campaigns exceed maxItems', () => {
render(
)
expect(screen.getByText('View all')).toBeInTheDocument()
})
it('does not show "View all" when campaigns do not exceed maxItems', () => {
render(
)
expect(screen.queryByText('View all')).not.toBeInTheDocument()
})
it('does not show "View all" when onViewAll is not provided', () => {
render()
expect(screen.queryByText('View all')).not.toBeInTheDocument()
})
it('calls onViewAll when "View all" button is clicked', () => {
const onViewAll = jest.fn()
render()
fireEvent.click(screen.getByText('View all'))
expect(onViewAll).toHaveBeenCalledTimes(1)
})
})
describe('progress bar color thresholds', () => {
it('applies success color for engagement >= 75', () => {
const { container } = render(
)
const bar = container.querySelector('[role="progressbar"]')
expect(bar).toHaveClass('bg-success-500')
})
it('applies primary color for engagement >= 50 and < 75', () => {
const { container } = render(
)
const bar = container.querySelector('[role="progressbar"]')
expect(bar).toHaveClass('bg-primary')
})
it('applies warning color for engagement >= 25 and < 50', () => {
const { container } = render(
)
const bar = container.querySelector('[role="progressbar"]')
expect(bar).toHaveClass('bg-warning-500')
})
it('applies gray color for engagement < 25', () => {
const { container } = render(
)
const bar = container.querySelector('[role="progressbar"]')
expect(bar).toHaveClass('bg-gray-400')
})
})
})
// ---------------------------------------------------------------------------
// DashboardGrid
// ---------------------------------------------------------------------------
describe('DashboardGrid', () => {
it('renders children', () => {
render(
A
B
)
expect(screen.getByTestId('child-a')).toBeInTheDocument()
expect(screen.getByTestId('child-b')).toBeInTheDocument()
})
it('defaults to 2 columns and md gap', () => {
const { container } = render()
const grid = container.firstChild as HTMLElement
expect(grid).toHaveClass('lg:grid-cols-2')
expect(grid).toHaveClass('gap-6')
})
it('applies lg:grid-cols-1 when columns=1', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('lg:grid-cols-1')
})
it('applies lg:grid-cols-3 when columns=3', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('lg:grid-cols-3')
})
it('applies sm:grid-cols-2 and lg:grid-cols-4 when columns=4', () => {
const { container } = render()
const grid = container.firstChild as HTMLElement
expect(grid).toHaveClass('sm:grid-cols-2')
expect(grid).toHaveClass('lg:grid-cols-4')
})
it('applies gap-4 for gap="sm"', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('gap-4')
})
it('applies gap-8 for gap="lg"', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('gap-8')
})
it('always has grid-cols-1 as the base class', () => {
const { container } = render()
expect(container.firstChild).toHaveClass('grid-cols-1')
})
it('applies a custom className', () => {
const { container } = render(
)
expect(container.firstChild).toHaveClass('my-grid')
})
})
// ---------------------------------------------------------------------------
// DashboardSection
// ---------------------------------------------------------------------------
describe('DashboardSection', () => {
it('renders the title', () => {
render()
expect(screen.getByText('Overview')).toBeInTheDocument()
})
it('renders children', () => {
render(
Content here
)
expect(screen.getByTestId('content')).toBeInTheDocument()
})
it('renders description when provided', () => {
render(
)
expect(screen.getByText('A summary of key metrics')).toBeInTheDocument()
})
it('does not render description when omitted', () => {
const { container } = render(
)
expect(container.querySelector('p')).not.toBeInTheDocument()
})
it('renders action node when provided', () => {
render(
Export}
>
)
expect(screen.getByTestId('action-btn')).toBeInTheDocument()
})
it('does not render the action wrapper when action is omitted', () => {
const { container } = render(
)
// The action wrapper div has class flex-shrink-0
expect(container.querySelector('.flex-shrink-0')).not.toBeInTheDocument()
})
it('renders the title as an h2 element', () => {
render()
expect(screen.getByRole('heading', { level: 2, name: 'My Section' })).toBeInTheDocument()
})
it('applies custom className', () => {
const { container } = render(
)
expect(container.firstChild).toHaveClass('my-section')
})
it('still renders correctly with all props provided', () => {
render(
Action}
className="full-class"
>
Slot content
)
expect(screen.getByText('Full Section')).toBeInTheDocument()
expect(screen.getByText('Detailed description')).toBeInTheDocument()
expect(screen.getByText('Action')).toBeInTheDocument()
expect(screen.getByTestId('slot')).toBeInTheDocument()
})
})