diff --git a/components/analytics/charts.test.tsx b/components/analytics/charts.test.tsx new file mode 100644 index 0000000..f3e4ece --- /dev/null +++ b/components/analytics/charts.test.tsx @@ -0,0 +1,185 @@ +import { render, screen } from '@testing-library/react' +import { AnalyticsCharts } from './charts' +import type { StreamStatus } from '@/types/stream' + +describe('AnalyticsCharts', () => { + const mockProps = { + series: [], + topTokens: [], + tokenShares: [], + totalVolume: 0n, + statusBreakdown: [], + topRecipients: [], + unlockProgress: { unlocked: 0n, deposited: 0n }, + } + + describe('empty state branches', () => { + it('should render empty state for series when no stream activity', () => { + render() + expect(screen.getByText('No stream activity yet for this period.')).toBeInTheDocument() + }) + + it('should render empty state for token distribution when no token shares', () => { + render() + expect(screen.getByText('No token distribution data available yet.')).toBeInTheDocument() + }) + + it('should render empty state for status breakdown when no status data', () => { + render() + expect(screen.getByText('No streams to break down yet.')).toBeInTheDocument() + }) + + it('should render empty state for top recipients when none exist', () => { + render() + expect(screen.getByText('No recipients yet.')).toBeInTheDocument() + }) + + it('should render empty state for top tokens when none exist', () => { + render() + expect(screen.getByText('No volume data yet.')).toBeInTheDocument() + }) + + it('should render empty state for unlock progress when no deposits', () => { + render() + expect(screen.getByText('No deposits to track yet.')).toBeInTheDocument() + }) + }) + + describe('populated data rendering', () => { + it('should render series chart when data is present', () => { + const seriesData = [ + { label: 'Day 1', count: 5, volume: 100 }, + { label: 'Day 2', count: 10, volume: 200 }, + ] + const { container } = render() + + // Check for AreaChart render (recharts component will be in the DOM) + expect(container.querySelector('.recharts-wrapper')).toBeInTheDocument() + }) + + it('should render correct number of token shares in pie chart', () => { + const tokenShares = [ + { symbol: 'USDC', amount: 1000n, count: 5, decimals: 6 }, + { symbol: 'USDT', amount: 500n, count: 3, decimals: 6 }, + { symbol: 'EUR', amount: 200n, count: 2, decimals: 2 }, + ] + render() + + // Check for each token symbol in the legend + expect(screen.getByText('USDC')).toBeInTheDocument() + expect(screen.getByText('USDT')).toBeInTheDocument() + expect(screen.getByText('EUR')).toBeInTheDocument() + }) + + it('should render status breakdown bar chart when status data exists', () => { + const statusBreakdown: Array<{ status: StreamStatus; count: number }> = [ + { status: 'streaming', count: 15 }, + { status: 'scheduled', count: 8 }, + { status: 'completed', count: 42 }, + ] + const { container } = render( + + ) + + // BarChart should render + expect(container.querySelector('.recharts-wrapper')).toBeInTheDocument() + }) + + it('should render top recipients list with correct addresses', () => { + const topRecipients = [ + { + address: 'GRZST3XVCDTUJ76ZAV2HA72KYOJ4LLH64JHCZGZ7V5FD5G7SRG4KDFR5', + federationName: null, + count: 12, + totals: [{ symbol: 'USDC', amount: 50000n, decimals: 6 }], + }, + { + address: 'GDZST2VCDTUJ76ZAV2HA72KYOJ4LLH64JHCZGZ7V5FD5G7SRG4KDTR2', + federationName: 'alice.stellar.expert', + count: 8, + totals: [{ symbol: 'USDT', amount: 30000n, decimals: 6 }], + }, + ] + render() + + // Federation name should be shown + expect(screen.getByText('alice.stellar.expert')).toBeInTheDocument() + // Stream count should be displayed + expect(screen.getByText('12 streams')).toBeInTheDocument() + expect(screen.getByText('8 streams')).toBeInTheDocument() + }) + + it('should render top tokens with correct count and amounts', () => { + const topTokens = [ + { symbol: 'USDC', amount: 100000n, count: 42, decimals: 6 }, + { symbol: 'USDT', amount: 75000n, count: 28, decimals: 6 }, + ] + render() + + expect(screen.getByText('USDC')).toBeInTheDocument() + expect(screen.getByText('42 streams')).toBeInTheDocument() + expect(screen.getByText('USDT')).toBeInTheDocument() + expect(screen.getByText('28 streams')).toBeInTheDocument() + }) + + it('should calculate and display unlock progress percentage', () => { + const unlockProgress = { + unlocked: 5000n, + deposited: 10000n, + } + render() + + // Should show 50% unlock progress + expect(screen.getByText('50.0%')).toBeInTheDocument() + }) + + it('should handle edge case where unlock progress is 0%', () => { + const unlockProgress = { + unlocked: 0n, + deposited: 10000n, + } + render() + + expect(screen.getByText('0.0%')).toBeInTheDocument() + }) + + it('should handle edge case where unlock progress is 100%', () => { + const unlockProgress = { + unlocked: 10000n, + deposited: 10000n, + } + render() + + expect(screen.getByText('100.0%')).toBeInTheDocument() + }) + }) + + describe('singular/plural forms', () => { + it('should display "stream" for single recipient stream', () => { + const topRecipients = [ + { + address: 'GRZST3XVCDTUJ76ZAV2HA72KYOJ4LLH64JHCZGZ7V5FD5G7SRG4KDFR5', + federationName: null, + count: 1, + totals: [{ symbol: 'USDC', amount: 1000n, decimals: 6 }], + }, + ] + render() + + expect(screen.getByText('1 stream')).toBeInTheDocument() + }) + }) + + describe('card titles and descriptions', () => { + it('should render all expected card titles', () => { + render() + + expect(screen.getByText('Streaming volume over time')).toBeInTheDocument() + expect(screen.getByText('Token distribution')).toBeInTheDocument() + expect(screen.getByText('Stream status breakdown')).toBeInTheDocument() + expect(screen.getByText('Top recipients')).toBeInTheDocument() + expect(screen.getByText('Top tokens by volume')).toBeInTheDocument() + expect(screen.getByText('Unlock progress')).toBeInTheDocument() + }) + }) +}) diff --git a/components/layout/navbar.test.tsx b/components/layout/navbar.test.tsx new file mode 100644 index 0000000..8485311 --- /dev/null +++ b/components/layout/navbar.test.tsx @@ -0,0 +1,399 @@ +import { render, screen, fireEvent, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Navbar } from './navbar' +import { usePathname } from 'next/navigation' +import { useTheme } from 'next-themes' +import { useNetwork } from '@/components/providers/network-provider' +import { useWalletContext } from '@/components/providers/wallet-provider' +import { useOnlineStatus } from '@/hooks/use-online-status' + +jest.mock('next/navigation') +jest.mock('next-themes') +jest.mock('@/components/providers/network-provider') +jest.mock('@/components/providers/wallet-provider') +jest.mock('@/hooks/use-online-status') +jest.mock('@/components/brand', () => ({ + Brand: () =>
Brand
, +})) +jest.mock('@/components/layout/notification-bell', () => ({ + NotificationBell: () =>
Notification Bell
, +})) +jest.mock('@/components/layout/connect-wallet-button', () => ({ + ConnectWalletButton: () =>
Connect Wallet
, +})) + +const mockUsePathname = usePathname as jest.MockedFunction +const mockUseTheme = useTheme as jest.MockedFunction +const mockUseNetwork = useNetwork as jest.MockedFunction +const mockUseWalletContext = useWalletContext as jest.MockedFunction +const mockUseOnlineStatus = useOnlineStatus as jest.MockedFunction + +describe('Navbar', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockUsePathname.mockReturnValue('/app') + mockUseTheme.mockReturnValue({ + theme: 'light', + setTheme: jest.fn(), + } as any) + mockUseNetwork.mockReturnValue({ + network: 'mainnet', + setNetwork: jest.fn(), + } as any) + mockUseWalletContext.mockReturnValue({ + isConnected: false, + networkMismatch: false, + walletNetwork: '', + } as any) + mockUseOnlineStatus.mockReturnValue(true) + }) + + describe('active link highlighting', () => { + it('should highlight Dashboard link when on /app route', () => { + mockUsePathname.mockReturnValue('/app') + render() + + const dashboardLink = screen.getByText('Dashboard').closest('a') + expect(dashboardLink).toHaveClass('bg-secondary', 'text-foreground') + }) + + it('should highlight Streams link when on /app/streams route', () => { + mockUsePathname.mockReturnValue('/app/streams') + render() + + const streamsLink = screen.getByText('Streams').closest('a') + expect(streamsLink).toHaveClass('bg-secondary', 'text-foreground') + }) + + it('should highlight Streams link when on /app/streams/123 (nested route)', () => { + mockUsePathname.mockReturnValue('/app/streams/123') + render() + + const streamsLink = screen.getByText('Streams').closest('a') + expect(streamsLink).toHaveClass('bg-secondary', 'text-foreground') + }) + + it('should highlight Analytics link when on /app/analytics route', () => { + mockUsePathname.mockReturnValue('/app/analytics') + render() + + const analyticsLink = screen.getByText('Analytics').closest('a') + expect(analyticsLink).toHaveClass('bg-secondary', 'text-foreground') + }) + + it('should highlight Settings link when on /app/settings route', () => { + mockUsePathname.mockReturnValue('/app/settings') + render() + + const settingsLink = screen.getByText('Settings').closest('a') + expect(settingsLink).toHaveClass('bg-secondary', 'text-foreground') + }) + + it('should not highlight other links when on one route', () => { + mockUsePathname.mockReturnValue('/app/streams') + render() + + const dashboardLink = screen.getByText('Dashboard').closest('a') + const analyticsLink = screen.getByText('Analytics').closest('a') + + expect(dashboardLink).not.toHaveClass('bg-secondary') + expect(analyticsLink).not.toHaveClass('bg-secondary') + }) + }) + + describe('network dropdown', () => { + it('should display current network in dropdown trigger', () => { + mockUseNetwork.mockReturnValue({ + network: 'mainnet', + setNetwork: jest.fn(), + } as any) + render() + + expect(screen.getByText('Mainnet')).toBeInTheDocument() + }) + + it('should display Testnet when network is testnet', () => { + mockUseNetwork.mockReturnValue({ + network: 'testnet', + setNetwork: jest.fn(), + } as any) + render() + + expect(screen.getByText('Testnet')).toBeInTheDocument() + }) + + it('should call setNetwork when selecting testnet', async () => { + const setNetworkMock = jest.fn() + mockUseNetwork.mockReturnValue({ + network: 'mainnet', + setNetwork: setNetworkMock, + } as any) + + const { container } = render() + const networkButton = container.querySelector('[aria-label*="network"]') || screen.getByText('Mainnet').closest('button') + + fireEvent.click(networkButton!) + + const testnetMenuItem = screen.getByText('Testnet') + fireEvent.click(testnetMenuItem) + + expect(setNetworkMock).toHaveBeenCalledWith('testnet') + }) + + it('should call setNetwork when selecting mainnet', async () => { + const setNetworkMock = jest.fn() + mockUseNetwork.mockReturnValue({ + network: 'testnet', + setNetwork: setNetworkMock, + } as any) + + const { container } = render() + const networkButton = screen.getByText('Testnet').closest('button') + + fireEvent.click(networkButton!) + + const mainnetMenuItem = screen.getByText('Mainnet') + fireEvent.click(mainnetMenuItem) + + expect(setNetworkMock).toHaveBeenCalledWith('mainnet') + }) + }) + + describe('theme dropdown', () => { + it('should render theme toggle button', () => { + render() + + const themeButton = screen.getByLabelText('Toggle theme') + expect(themeButton).toBeInTheDocument() + }) + + it('should call setTheme with light when Light option selected', () => { + const setThemeMock = jest.fn() + mockUseTheme.mockReturnValue({ + theme: 'dark', + setTheme: setThemeMock, + } as any) + + const { container } = render() + const themeButton = screen.getByLabelText('Toggle theme') + + fireEvent.click(themeButton) + + const lightOption = screen.getByText('Light') + fireEvent.click(lightOption) + + expect(setThemeMock).toHaveBeenCalledWith('light') + }) + + it('should call setTheme with dark when Dark option selected', () => { + const setThemeMock = jest.fn() + mockUseTheme.mockReturnValue({ + theme: 'light', + setTheme: setThemeMock, + } as any) + + const { container } = render() + const themeButton = screen.getByLabelText('Toggle theme') + + fireEvent.click(themeButton) + + const darkOption = screen.getByText('Dark') + fireEvent.click(darkOption) + + expect(setThemeMock).toHaveBeenCalledWith('dark') + }) + + it('should call setTheme with system when System option selected', () => { + const setThemeMock = jest.fn() + mockUseTheme.mockReturnValue({ + theme: 'light', + setTheme: setThemeMock, + } as any) + + const { container } = render() + const themeButton = screen.getByLabelText('Toggle theme') + + fireEvent.click(themeButton) + + const systemOption = screen.getByText('System') + fireEvent.click(systemOption) + + expect(setThemeMock).toHaveBeenCalledWith('system') + }) + }) + + describe('network mismatch banner', () => { + it('should not render banner when wallet not connected', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: false, + networkMismatch: true, + walletNetwork: 'testnet', + } as any) + + render() + + expect(screen.queryByText(/Your wallet is on/)).not.toBeInTheDocument() + }) + + it('should not render banner when networks match', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: false, + walletNetwork: 'mainnet', + } as any) + + render() + + expect(screen.queryByText(/Your wallet is on/)).not.toBeInTheDocument() + }) + + it('should render banner when isConnected && networkMismatch', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: true, + walletNetwork: 'testnet', + } as any) + mockUseNetwork.mockReturnValue({ + network: 'mainnet', + setNetwork: jest.fn(), + } as any) + + render() + + expect(screen.getByText(/Your wallet is on/)).toBeInTheDocument() + expect(screen.getByText(/testnet/)).toBeInTheDocument() + expect(screen.getByText(/mainnet/)).toBeInTheDocument() + }) + + it('should display correct wallet and expected networks in mismatch banner', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: true, + walletNetwork: 'testnet', + } as any) + mockUseNetwork.mockReturnValue({ + network: 'mainnet', + setNetwork: jest.fn(), + } as any) + + render() + + const banner = screen.getByText(/Your wallet is on/) + expect(banner).toHaveTextContent('Your wallet is on testnet') + expect(banner).toHaveTextContent('switch to mainnet') + }) + + it('should have AlertTriangle icon in banner', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: true, + walletNetwork: 'testnet', + } as any) + + const { container } = render() + + // AlertTriangle SVG should be present in banner + const banner = screen.getByText(/Your wallet is on/).closest('div') + expect(banner).toBeInTheDocument() + }) + }) + + describe('offline status', () => { + it('should not show offline indicator when online', () => { + mockUseOnlineStatus.mockReturnValue(true) + render() + + expect(screen.queryByText('Offline')).not.toBeInTheDocument() + }) + + it('should show offline indicator when offline', () => { + mockUseOnlineStatus.mockReturnValue(false) + render() + + expect(screen.getByText('Offline')).toBeInTheDocument() + }) + + it('should have offline status role', () => { + mockUseOnlineStatus.mockReturnValue(false) + render() + + const offlineIndicator = screen.getByRole('status') + expect(offlineIndicator).toBeInTheDocument() + expect(offlineIndicator).toHaveAttribute('title', expect.stringContaining('offline')) + }) + }) + + describe('new stream button state', () => { + it('should have new stream button enabled when no network mismatch', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: false, + walletNetwork: 'mainnet', + } as any) + + render() + + const newStreamButton = screen.getByText('New stream').closest('a')?.closest('[role="button"], button') || + screen.getByText('New stream').closest('button') + expect(newStreamButton).not.toBeDisabled() + }) + + it('should have new stream button disabled when network mismatch', () => { + mockUseWalletContext.mockReturnValue({ + isConnected: true, + networkMismatch: true, + walletNetwork: 'testnet', + } as any) + + render() + + const newStreamButton = screen.getByText(/New stream/).closest('button') + expect(newStreamButton).toBeDisabled() + }) + }) + + describe('mobile navigation', () => { + it('should render mobile nav with same links as desktop', () => { + render() + + const allDashboardLinks = screen.getAllByText('Dashboard') + const allStreamsLinks = screen.getAllByText('Streams') + const allAnalyticsLinks = screen.getAllByText('Analytics') + const allSettingsLinks = screen.getAllByText('Settings') + + // Each should appear at least twice (desktop + mobile) + expect(allDashboardLinks.length).toBeGreaterThanOrEqual(2) + expect(allStreamsLinks.length).toBeGreaterThanOrEqual(2) + expect(allAnalyticsLinks.length).toBeGreaterThanOrEqual(2) + expect(allSettingsLinks.length).toBeGreaterThanOrEqual(2) + }) + + it('should highlight active link in mobile nav', () => { + mockUsePathname.mockReturnValue('/app/streams') + render() + + const streamsLinks = screen.getAllByText('Streams') + const activeLink = streamsLinks.find((link) => link.closest('a')?.className.includes('bg-secondary')) + + expect(activeLink).toBeInTheDocument() + }) + }) + + describe('core components presence', () => { + it('should render Brand component', () => { + render() + expect(screen.getByTestId('brand')).toBeInTheDocument() + }) + + it('should render NotificationBell component', () => { + render() + expect(screen.getByTestId('notification-bell')).toBeInTheDocument() + }) + + it('should render ConnectWalletButton component', () => { + render() + expect(screen.getByTestId('connect-wallet')).toBeInTheDocument() + }) + }) +})