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