diff --git a/.gitignore b/.gitignore index 9946fef..ceb9374 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ contracts/**/test_snapshots/ # Vitest snapshots and coverage *.snap +**/__snapshots__/ coverage/ # TypeScript build info cache diff --git a/__tests__/components/providers/wallet-provider.test.tsx b/__tests__/components/providers/wallet-provider.test.tsx new file mode 100644 index 0000000..38db497 --- /dev/null +++ b/__tests__/components/providers/wallet-provider.test.tsx @@ -0,0 +1,498 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act, renderHook, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' + +// ─── Mocks — declared before any imports of modules under test ──────────────── + +// Contract integration +vi.mock('@/lib/contract', () => ({ + setSignTransaction: vi.fn(), +})) + +// Sentry user tracking +vi.mock('@/lib/sentry', () => ({ + setSentryUser: vi.fn(), +})) + +// Network provider — always returns testnet +vi.mock('@/components/providers/network-provider', () => ({ + useNetwork: () => ({ network: 'testnet' }), +})) + +// Stellar network config +vi.mock('@/lib/stellar', () => ({ + getNetworkConfig: vi.fn((_network: string) => ({ + passphrase: 'Test SDF Network ; September 2015', + rpcUrl: 'https://soroban-testnet.stellar.org', + })), +})) + +// ─── Freighter API mock ─────────────────────────────────────────────────────── +// The adapter uses dynamic import(), so we mock the package itself. +const mockFreighterIsConnected = vi.fn() +const mockFreighterRequestAccess = vi.fn() +const mockFreighterGetAddress = vi.fn() +const mockFreighterSignTransaction = vi.fn() +const mockFreighterGetNetwork = vi.fn() + +vi.mock('@stellar/freighter-api', () => ({ + isConnected: () => mockFreighterIsConnected(), + requestAccess: () => mockFreighterRequestAccess(), + getAddress: () => mockFreighterGetAddress(), + signTransaction: (xdr: string, opts: unknown) => mockFreighterSignTransaction(xdr, opts), + getNetwork: () => mockFreighterGetNetwork(), +})) + +// ─── Albedo mock ────────────────────────────────────────────────────────────── +const mockAlbedoPublicKey = vi.fn() +const mockAlbedoTx = vi.fn() + +vi.mock('@albedo-link/intent', () => ({ + default: { + publicKey: (opts: unknown) => mockAlbedoPublicKey(opts), + tx: (opts: unknown) => mockAlbedoTx(opts), + }, +})) + +// ─── WalletConnect mocks (prevent real network calls) ───────────────────────── +vi.mock('@walletconnect/sign-client', () => ({ + SignClient: { init: vi.fn().mockResolvedValue({}) }, +})) +vi.mock('@walletconnect/modal', () => ({ + WalletConnectModal: vi.fn().mockImplementation(() => ({ + openModal: vi.fn(), + closeModal: vi.fn(), + })), +})) + +// ─── Import modules under test AFTER mocks ──────────────────────────────────── +import { WalletProvider, useWalletContext, ADAPTERS, WALLET_OPTIONS } from '@/components/providers/wallet-provider' +import { setSignTransaction } from '@/lib/contract' +import { setSentryUser } from '@/lib/sentry' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const TEST_ADDRESS = 'GABCDE1234567890TESTADDRESSSTELLARXLM00' + +function renderWithProvider(ui: React.ReactNode) { + return render({ui}) +} + +/** Consumer component that exposes the wallet context via data-testid attributes. */ +function WalletConsumer() { + const ctx = useWalletContext() + return ( +
+ {ctx.address ?? 'null'} + {ctx.walletId ?? 'null'} + {String(ctx.isConnected)} + {String(ctx.reconnecting)} + {String(ctx.connecting)} + {String(ctx.networkMismatch)} + {ctx.walletNetwork ?? 'null'} + + + +
+ ) +} + +// ─── Tests ───────────────────────────────────────────────────────────────────── + +describe('WalletProvider', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + + // Default Freighter: not connected, no network + mockFreighterIsConnected.mockResolvedValue({ isConnected: false }) + mockFreighterGetAddress.mockResolvedValue({ address: '' }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + mockFreighterGetNetwork.mockResolvedValue({ network: 'TESTNET', error: undefined }) + mockFreighterSignTransaction.mockResolvedValue({ signedTxXdr: 'signed-xdr', error: undefined }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + // ─── initial state ────────────────────────────────────────────────────────── + + describe('initial state', () => { + it('renders children', async () => { + renderWithProvider(child) + await act(async () => {}) + expect(screen.getByText('child')).toBeInTheDocument() + }) + + it('starts with address = null', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('address').textContent).toBe('null') + }) + + it('starts with walletId = null', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('wallet-id').textContent).toBe('null') + }) + + it('starts with isConnected = false', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('is-connected').textContent).toBe('false') + }) + + it('sets reconnecting to false after mount when no saved walletId', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('reconnecting').textContent).toBe('false') + }) + + it('starts with networkMismatch = false', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('network-mismatch').textContent).toBe('false') + }) + }) + + // ─── connect flow ─────────────────────────────────────────────────────────── + + describe('connect flow', () => { + beforeEach(() => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + }) + + it('sets address after successful Freighter connect', async () => { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(screen.getByTestId('address').textContent).toBe(TEST_ADDRESS) + }) + }) + + it('sets walletId to "freighter" after connect', async () => { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(screen.getByTestId('wallet-id').textContent).toBe('freighter') + }) + }) + + it('sets isConnected to true after connect', async () => { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(screen.getByTestId('is-connected').textContent).toBe('true') + }) + }) + + it('persists walletId to localStorage after connect', async () => { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(localStorage.getItem('walletId')).toBe('freighter') + }) + }) + + it('calls setSentryUser with the address after connect', async () => { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(setSentryUser).toHaveBeenCalledWith(TEST_ADDRESS) + }) + }) + }) + + // ─── disconnect flow ──────────────────────────────────────────────────────── + + describe('disconnect flow', () => { + beforeEach(() => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + }) + + async function connectFreighter() { + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + await waitFor(() => + expect(screen.getByTestId('is-connected').textContent).toBe('true'), + ) + return user + } + + it('clears address on disconnect', async () => { + const user = await connectFreighter() + await user.click(screen.getByRole('button', { name: 'disconnect' })) + expect(screen.getByTestId('address').textContent).toBe('null') + }) + + it('clears walletId on disconnect', async () => { + const user = await connectFreighter() + await user.click(screen.getByRole('button', { name: 'disconnect' })) + expect(screen.getByTestId('wallet-id').textContent).toBe('null') + }) + + it('sets isConnected to false on disconnect', async () => { + const user = await connectFreighter() + await user.click(screen.getByRole('button', { name: 'disconnect' })) + expect(screen.getByTestId('is-connected').textContent).toBe('false') + }) + + it('removes walletId from localStorage on disconnect', async () => { + const user = await connectFreighter() + await user.click(screen.getByRole('button', { name: 'disconnect' })) + expect(localStorage.getItem('walletId')).toBeNull() + }) + + it('calls setSentryUser(null) on disconnect', async () => { + const user = await connectFreighter() + await user.click(screen.getByRole('button', { name: 'disconnect' })) + expect(setSentryUser).toHaveBeenCalledWith(null) + }) + }) + + // ─── persisted connection restore on mount ────────────────────────────────── + + describe('persisted-connection restore on mount', () => { + it('auto-reconnects when walletId is stored in localStorage', async () => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + + localStorage.setItem('walletId', 'freighter') + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('address').textContent).toBe(TEST_ADDRESS) + }) + + expect(screen.getByTestId('wallet-id').textContent).toBe('freighter') + expect(screen.getByTestId('is-connected').textContent).toBe('true') + }) + + it('reconnecting is false after restore completes', async () => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + + localStorage.setItem('walletId', 'freighter') + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('reconnecting').textContent).toBe('false') + }) + }) + + it('clears localStorage and stays disconnected when reconnect fails', async () => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: false }) + mockFreighterGetAddress.mockResolvedValue({ address: '', error: 'Not connected' }) + + localStorage.setItem('walletId', 'freighter') + + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('reconnecting').textContent).toBe('false') + }) + + expect(localStorage.getItem('walletId')).toBeNull() + expect(screen.getByTestId('is-connected').textContent).toBe('false') + }) + + it('stays disconnected when no walletId is saved', async () => { + renderWithProvider() + + await waitFor(() => { + expect(screen.getByTestId('reconnecting').textContent).toBe('false') + }) + + expect(screen.getByTestId('is-connected').textContent).toBe('false') + }) + }) + + // ─── network-mismatch detection ───────────────────────────────────────────── + + describe('network-mismatch detection', () => { + it('networkMismatch is false when walletId is not freighter', async () => { + // Albedo connect + mockAlbedoPublicKey.mockResolvedValue({ pubkey: TEST_ADDRESS }) + + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-albedo' })) + + await waitFor(() => { + expect(screen.getByTestId('is-connected').textContent).toBe('true') + }) + + expect(screen.getByTestId('network-mismatch').textContent).toBe('false') + }) + + it('networkMismatch is false when walletNetwork matches app network', async () => { + // Freighter on testnet — app network is also testnet + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + mockFreighterGetNetwork.mockResolvedValue({ network: 'TESTNET', error: undefined }) + + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(screen.getByTestId('is-connected').textContent).toBe('true') + }) + + // Give the network poll time to run + await waitFor(() => { + expect(screen.getByTestId('wallet-network').textContent).toBe('TESTNET') + }) + + expect(screen.getByTestId('network-mismatch').textContent).toBe('false') + }) + + it('networkMismatch is true when Freighter is on mainnet but app is testnet', async () => { + mockFreighterIsConnected.mockResolvedValue({ isConnected: true }) + mockFreighterGetAddress.mockResolvedValue({ address: TEST_ADDRESS }) + mockFreighterRequestAccess.mockResolvedValue(undefined) + // Freighter reports mainnet + mockFreighterGetNetwork.mockResolvedValue({ network: 'PUBLIC', error: undefined }) + + const user = userEvent.setup() + renderWithProvider() + await act(async () => {}) + + await user.click(screen.getByRole('button', { name: 'connect-freighter' })) + + await waitFor(() => { + expect(screen.getByTestId('is-connected').textContent).toBe('true') + }) + + await waitFor(() => { + expect(screen.getByTestId('network-mismatch').textContent).toBe('true') + }) + }) + + it('networkMismatch is false when disconnected', async () => { + renderWithProvider() + await act(async () => {}) + expect(screen.getByTestId('network-mismatch').textContent).toBe('false') + }) + }) + + // ─── useWalletContext outside provider ────────────────────────────────────── + + describe('useWalletContext outside WalletProvider', () => { + it('throws when used outside WalletProvider', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + expect(() => { + renderHook(() => useWalletContext()) + }).toThrow('useWallet must be used within a WalletProvider') + + consoleError.mockRestore() + }) + }) + + // ─── setSignTransaction integration ───────────────────────────────────────── + + describe('setSignTransaction integration', () => { + it('calls setSignTransaction on mount', async () => { + renderWithProvider() + await act(async () => {}) + expect(setSignTransaction).toHaveBeenCalled() + }) + }) +}) + +// ─── WALLET_OPTIONS ─────────────────────────────────────────────────────────── + +describe('WALLET_OPTIONS', () => { + it('exports four wallet options', () => { + expect(WALLET_OPTIONS).toHaveLength(4) + }) + + it('includes freighter', () => { + expect(WALLET_OPTIONS.some((o) => o.id === 'freighter')).toBe(true) + }) + + it('includes xbull', () => { + expect(WALLET_OPTIONS.some((o) => o.id === 'xbull')).toBe(true) + }) + + it('includes lobstr', () => { + expect(WALLET_OPTIONS.some((o) => o.id === 'lobstr')).toBe(true) + }) + + it('includes albedo', () => { + expect(WALLET_OPTIONS.some((o) => o.id === 'albedo')).toBe(true) + }) + + it('each option has id, name, and detail fields', () => { + for (const option of WALLET_OPTIONS) { + expect(option.id).toBeTruthy() + expect(option.name).toBeTruthy() + expect(option.detail).toBeTruthy() + } + }) +}) + +// ─── ADAPTERS registry ──────────────────────────────────────────────────────── + +describe('ADAPTERS registry', () => { + it('exports adapters for freighter, xbull, lobstr, and albedo', () => { + expect(ADAPTERS).toHaveProperty('freighter') + expect(ADAPTERS).toHaveProperty('xbull') + expect(ADAPTERS).toHaveProperty('lobstr') + expect(ADAPTERS).toHaveProperty('albedo') + }) + + it('each adapter has connect, signTransaction, and isAvailable methods', () => { + for (const [, adapter] of Object.entries(ADAPTERS)) { + expect(typeof adapter.connect).toBe('function') + expect(typeof adapter.signTransaction).toBe('function') + expect(typeof adapter.isAvailable).toBe('function') + } + }) + + it('albedo adapter isAvailable returns true (web-based, always available)', () => { + expect(ADAPTERS.albedo.isAvailable()).toBe(true) + }) + + it('lobstr adapter isAvailable returns true (WalletConnect fallback)', () => { + expect(ADAPTERS.lobstr.isAvailable()).toBe(true) + }) +}) diff --git a/__tests__/components/ui/accessible-countdown-timer.test.tsx b/__tests__/components/ui/accessible-countdown-timer.test.tsx new file mode 100644 index 0000000..89eccc5 --- /dev/null +++ b/__tests__/components/ui/accessible-countdown-timer.test.tsx @@ -0,0 +1,298 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { AccessibleCountdownTimer } from '@/components/ui/accessible-countdown-timer' + +// ─── time helpers ───────────────────────────────────────────────────────────── + +const SECONDS_PER_DAY = 86_400 +const SECONDS_PER_HOUR = 3_600 +const SECONDS_PER_MINUTE = 60 + +/** Returns a frozen unix timestamp (seconds) so tests are deterministic. */ +function frozenNow(): number { + return Math.floor(Date.now() / 1000) +} + +// ─── mock useNow to return a controllable value ─────────────────────────────── + +let mockNow = frozenNow() + +vi.mock('@/hooks/use-now', () => ({ + useNow: () => mockNow, +})) + +// ─── AccessibleCountdownTimer ───────────────────────────────────────────────── + +describe('AccessibleCountdownTimer', () => { + beforeEach(() => { + // Reset to a stable "now" before every test + mockNow = frozenNow() + }) + + // ─── display text ─────────────────────────────────────────────────────────── + + describe('display text (active timer)', () => { + it('shows remaining days when target is many days in the future', () => { + const target = BigInt(mockNow + 5 * SECONDS_PER_DAY + 3 * SECONDS_PER_HOUR) + render() + expect(screen.getByText(/5d/)).toBeInTheDocument() + }) + + it('includes hours alongside days in the output', () => { + const target = BigInt(mockNow + 5 * SECONDS_PER_DAY + 3 * SECONDS_PER_HOUR) + render() + expect(screen.getByText(/3h/)).toBeInTheDocument() + }) + + it('shows seconds when target is less than a minute away', () => { + const target = BigInt(mockNow + 45) + render() + expect(screen.getByText(/45s/)).toBeInTheDocument() + }) + + it('shows minutes when target is a few minutes away', () => { + const target = BigInt(mockNow + 7 * SECONDS_PER_MINUTE + 30) + render() + expect(screen.getByText(/7m/)).toBeInTheDocument() + }) + + it('does not show the ended label for a future target', () => { + const target = BigInt(mockNow + 10 * SECONDS_PER_DAY) + render() + expect(screen.queryByText('Ended')).not.toBeInTheDocument() + }) + + it('uses font-mono tabular-nums classes for digit alignment', () => { + const target = BigInt(mockNow + 2 * SECONDS_PER_DAY) + render() + const span = screen.getByText(/2d/) + expect(span).toHaveClass('font-mono') + expect(span).toHaveClass('tabular-nums') + }) + + it('applies a custom className to the display span', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const span = screen.getByText(/\d+h/) + expect(span).toHaveClass('text-green-500') + }) + }) + + // ─── expired target ───────────────────────────────────────────────────────── + + describe('expired target (past timestamp)', () => { + it('displays the default "Ended" label when target has passed', () => { + const target = BigInt(mockNow - 60) + render() + expect(screen.getByText('Ended')).toBeInTheDocument() + }) + + it('shows "Ended" when target equals now exactly', () => { + const target = BigInt(mockNow) + render() + expect(screen.getByText('Ended')).toBeInTheDocument() + }) + + it('shows a custom endedLabel when provided', () => { + const target = BigInt(mockNow - 1) + render() + expect(screen.getByText('Stream complete')).toBeInTheDocument() + }) + + it('does not show countdown text when expired', () => { + const target = BigInt(mockNow - SECONDS_PER_DAY) + render() + expect(screen.queryByText(/\d+d/)).not.toBeInTheDocument() + expect(screen.queryByText(/\d+h/)).not.toBeInTheDocument() + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + }) + }) + + // ─── aria-label on display span ───────────────────────────────────────────── + + describe('aria-label on display span', () => { + it('includes "Time remaining:" in aria-label when active', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const span = screen.getByText(/\d+h/) + expect(span).toHaveAttribute('aria-label') + expect(span.getAttribute('aria-label')).toMatch(/time remaining:/i) + }) + + it('has aria-live="off" on the display span', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const span = screen.getByText(/\d+h/) + expect(span).toHaveAttribute('aria-live', 'off') + }) + + it('sets aria-label to ended label when expired', () => { + const target = BigInt(mockNow - 60) + render() + const span = screen.getByText('Done') + expect(span.getAttribute('aria-label')).toMatch(/Done/) + }) + }) + + // ─── live announcement region ─────────────────────────────────────────────── + + describe('live announcement region', () => { + it('renders a region with role="status"', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('status region has aria-live="polite"', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const region = screen.getByRole('status') + expect(region).toHaveAttribute('aria-live', 'polite') + }) + + it('status region has aria-atomic="true"', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const region = screen.getByRole('status') + expect(region).toHaveAttribute('aria-atomic', 'true') + }) + + it('status region is visually hidden via sr-only class', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const region = screen.getByRole('status') + expect(region).toHaveClass('sr-only') + }) + + it('starts with empty announcement text', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + expect(screen.getByRole('status').textContent).toBe('') + }) + + it('populates announcement when timer transitions from active to expired', () => { + const onStateChange = vi.fn() + const target = BigInt(mockNow + 1) // active + + const { rerender } = render( + , + ) + + // Simulate time advancing past the target + act(() => { + mockNow = Number(target) + 1 + }) + + rerender() + + const region = screen.getByRole('status') + expect(region.textContent).toMatch(/countdown finished/i) + }) + + it('uses custom endedLabel in the expiry announcement', () => { + const target = BigInt(mockNow + 1) + + const { rerender } = render( + , + ) + + act(() => { + mockNow = Number(target) + 1 + }) + + rerender() + + const region = screen.getByRole('status') + expect(region.textContent).toContain('Vesting complete') + }) + + it('populates announcement when button is clicked', async () => { + const user = userEvent.setup() + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + + const btn = screen.getByRole('button', { name: /announce current time remaining/i }) + await user.click(btn) + + const region = screen.getByRole('status') + expect(region.textContent).toMatch(/time remaining:/i) + }) + }) + + // ─── announce button ───────────────────────────────────────────────────────── + + describe('announce button', () => { + it('renders the announce button by default', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + expect( + screen.getByRole('button', { name: /announce current time remaining/i }), + ).toBeInTheDocument() + }) + + it('hides the announce button when hideButton is true', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + expect( + screen.queryByRole('button', { name: /announce current time remaining/i }), + ).not.toBeInTheDocument() + }) + + it('button title describes its purpose', () => { + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + const btn = screen.getByRole('button', { name: /announce current time remaining/i }) + expect(btn).toHaveAttribute('title') + }) + + it('announcement says "Ended" when timer expired and button clicked', async () => { + const user = userEvent.setup() + const target = BigInt(mockNow - 60) + render() + + const btn = screen.getByRole('button', { name: /announce current time remaining/i }) + await user.click(btn) + + const region = screen.getByRole('status') + expect(region.textContent).toContain('Ended') + }) + }) + + // ─── onStateChange callback ───────────────────────────────────────────────── + + describe('onStateChange callback', () => { + it('does not call onStateChange on initial render when already active', () => { + const onStateChange = vi.fn() + const target = BigInt(mockNow + SECONDS_PER_HOUR) + render() + expect(onStateChange).not.toHaveBeenCalled() + }) + + it('does not call onStateChange on initial render when already expired', () => { + const onStateChange = vi.fn() + const target = BigInt(mockNow - SECONDS_PER_HOUR) + render() + expect(onStateChange).not.toHaveBeenCalled() + }) + + it('fires onStateChange with "expired" when timer crosses zero', () => { + const onStateChange = vi.fn() + const target = BigInt(mockNow + 1) // currently active + + const { rerender } = render( + , + ) + + act(() => { + mockNow = Number(target) + 1 + }) + + rerender() + + expect(onStateChange).toHaveBeenCalledWith('expired') + expect(onStateChange).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/__tests__/components/ui/badge.test.tsx b/__tests__/components/ui/badge.test.tsx new file mode 100644 index 0000000..5d7768b --- /dev/null +++ b/__tests__/components/ui/badge.test.tsx @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Badge, badgeVariants } from '@/components/ui/badge' + +// ─── Badge ──────────────────────────────────────────────────────────────────── + +describe('Badge', () => { + // ─── base classes ─────────────────────────────────────────────────────────── + + describe('base classes', () => { + it('renders a by default', () => { + const { container } = render(Label) + expect(container.querySelector('span')).toBeInTheDocument() + }) + + it('has inline-flex class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('inline-flex') + }) + + it('has h-5 class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('h-5') + }) + + it('has text-xs class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('text-xs') + }) + + it('has font-medium class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('font-medium') + }) + + it('has rounded-4xl class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('rounded-4xl') + }) + + it('has whitespace-nowrap class', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('whitespace-nowrap') + }) + + it('renders children', () => { + render(Active) + expect(screen.getByText('Active')).toBeInTheDocument() + }) + }) + + // ─── variants ─────────────────────────────────────────────────────────────── + + describe('variants', () => { + it('renders default variant (bg-primary) when no variant is provided', () => { + const { container } = render(Default) + const el = container.querySelector('span') + expect(el?.className).toContain('bg-primary') + expect(el?.className).toContain('text-primary-foreground') + }) + + it('renders default variant explicitly', () => { + const { container } = render(Default) + const el = container.querySelector('span') + expect(el?.className).toContain('bg-primary') + expect(el?.className).toContain('text-primary-foreground') + }) + + it('renders secondary variant with correct classes', () => { + const { container } = render(Secondary) + const el = container.querySelector('span') + expect(el?.className).toContain('bg-secondary') + expect(el?.className).toContain('text-secondary-foreground') + }) + + it('renders destructive variant with correct classes', () => { + const { container } = render(Destructive) + const el = container.querySelector('span') + expect(el?.className).toContain('bg-destructive/10') + expect(el?.className).toContain('text-destructive') + }) + + it('renders outline variant with correct classes', () => { + const { container } = render(Outline) + const el = container.querySelector('span') + expect(el?.className).toContain('border-border') + expect(el?.className).toContain('text-foreground') + }) + + it('renders ghost variant with correct classes', () => { + const { container } = render(Ghost) + const el = container.querySelector('span') + expect(el?.className).toContain('hover:bg-muted') + expect(el?.className).toContain('hover:text-muted-foreground') + }) + + it('renders link variant with correct classes', () => { + const { container } = render(Link) + const el = container.querySelector('span') + expect(el?.className).toContain('text-primary') + expect(el?.className).toContain('underline-offset-4') + }) + + it.each([ + ['default', 'bg-primary'], + ['secondary', 'bg-secondary'], + ['destructive', 'bg-destructive/10'], + ['outline', 'border-border'], + ['ghost', 'hover:bg-muted'], + ['link', 'underline-offset-4'], + ] as const)( + 'variant "%s" includes class "%s"', + (variant, expectedClass) => { + const { container } = render({variant}) + const el = container.querySelector('span') + expect(el?.className).toContain(expectedClass) + }, + ) + }) + + // ─── className merging ─────────────────────────────────────────────────────── + + describe('className merging', () => { + it('merges a custom className with variant classes', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('custom-badge') + expect(el?.className).toContain('bg-primary') // default variant still present + }) + + it('preserves base classes when a custom className is added', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el?.className).toContain('inline-flex') + expect(el?.className).toContain('my-extra') + }) + }) + + // ─── render prop (polymorphic element) ────────────────────────────────────── + + describe('render prop', () => { + it('renders an tag when render prop overrides the tag', () => { + const { container } = render( + }>View Status, + ) + const link = container.querySelector('a') + expect(link).toBeInTheDocument() + expect(link?.href).toContain('/status') + }) + + it('applies Badge classes to the rendered tag', () => { + const { container } = render( + }> + Link Badge + , + ) + const link = container.querySelector('a') + expect(link?.className).toContain('bg-secondary') + expect(link?.className).toContain('inline-flex') + }) + + it('renders children inside the custom element', () => { + render( + }>Go Here, + ) + expect(screen.getByText('Go Here')).toBeInTheDocument() + }) + }) + + // ─── HTML attribute passthrough ────────────────────────────────────────────── + + describe('HTML attribute passthrough', () => { + it('passes aria-label to the span', () => { + const { container } = render(Active) + const el = container.querySelector('span') + expect(el).toHaveAttribute('aria-label', 'status badge') + }) + + it('passes data attributes through', () => { + const { container } = render(Label) + const el = container.querySelector('span') + expect(el).toHaveAttribute('data-testid', 'my-badge') + }) + }) +}) + +// ─── badgeVariants CVA ──────────────────────────────────────────────────────── + +describe('badgeVariants CVA', () => { + it('generates correct classes for default variant', () => { + const classes = badgeVariants({ variant: 'default' }) + expect(classes).toContain('bg-primary') + expect(classes).toContain('text-primary-foreground') + expect(classes).toContain('inline-flex') + }) + + it('generates correct classes for secondary variant', () => { + const classes = badgeVariants({ variant: 'secondary' }) + expect(classes).toContain('bg-secondary') + expect(classes).toContain('text-secondary-foreground') + }) + + it('generates correct classes for destructive variant', () => { + const classes = badgeVariants({ variant: 'destructive' }) + expect(classes).toContain('bg-destructive/10') + expect(classes).toContain('text-destructive') + }) + + it('generates correct classes for outline variant', () => { + const classes = badgeVariants({ variant: 'outline' }) + expect(classes).toContain('border-border') + expect(classes).toContain('text-foreground') + }) + + it('generates correct classes for ghost variant', () => { + const classes = badgeVariants({ variant: 'ghost' }) + expect(classes).toContain('hover:bg-muted') + }) + + it('generates correct classes for link variant', () => { + const classes = badgeVariants({ variant: 'link' }) + expect(classes).toContain('text-primary') + expect(classes).toContain('underline-offset-4') + }) + + it('uses default variant when none specified', () => { + const classes = badgeVariants({}) + expect(classes).toContain('bg-primary') + }) + + it('always includes base inline-flex class regardless of variant', () => { + const variants = ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const + for (const variant of variants) { + const classes = badgeVariants({ variant }) + expect(classes).toContain('inline-flex') + } + }) + + it('always includes h-5 class regardless of variant', () => { + const variants = ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const + for (const variant of variants) { + const classes = badgeVariants({ variant }) + expect(classes).toContain('h-5') + } + }) +}) diff --git a/__tests__/components/ui/card.test.tsx b/__tests__/components/ui/card.test.tsx new file mode 100644 index 0000000..812c136 --- /dev/null +++ b/__tests__/components/ui/card.test.tsx @@ -0,0 +1,289 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import React from 'react' +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardAction, + CardContent, + CardFooter, +} from '@/components/ui/card' + +// ─── Card ───────────────────────────────────────────────────────────────────── + +describe('Card', () => { + describe('data-slot attribute', () => { + it('has data-slot="card"', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el).toBeInTheDocument() + }) + }) + + describe('data-size attribute', () => { + it('defaults data-size to "default"', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el).toHaveAttribute('data-size', 'default') + }) + + it('sets data-size="sm" when size prop is "sm"', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el).toHaveAttribute('data-size', 'sm') + }) + + it('sets data-size="default" when size prop is explicitly "default"', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el).toHaveAttribute('data-size', 'default') + }) + }) + + describe('children', () => { + it('renders children', () => { + render(Hello Card) + expect(screen.getByText('Hello Card')).toBeInTheDocument() + }) + }) + + describe('className', () => { + it('merges a custom className', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el?.className).toContain('custom-card') + }) + + it('keeps built-in rounded-xl class when custom className is added', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el?.className).toContain('rounded-xl') + }) + }) + + describe('HTML attribute passthrough', () => { + it('passes arbitrary HTML attributes through', () => { + const { container } = render(content) + const el = container.querySelector('[data-slot="card"]') + expect(el).toHaveAttribute('aria-label', 'stream card') + }) + }) +}) + +// ─── CardHeader ─────────────────────────────────────────────────────────────── + +describe('CardHeader', () => { + it('has data-slot="card-header"', () => { + const { container } = render(header) + const el = container.querySelector('[data-slot="card-header"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render(Header Content) + expect(screen.getByText('Header Content')).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(header) + const el = container.querySelector('[data-slot="card-header"]') + expect(el?.className).toContain('my-header') + }) + + it('passes arbitrary HTML attributes through', () => { + const { container } = render(header) + const el = container.querySelector('[data-slot="card-header"]') + expect(el).toHaveAttribute('id', 'card-hdr') + }) +}) + +// ─── CardTitle ──────────────────────────────────────────────────────────────── + +describe('CardTitle', () => { + it('has data-slot="card-title"', () => { + const { container } = render(Title) + const el = container.querySelector('[data-slot="card-title"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render(My Stream) + expect(screen.getByText('My Stream')).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(Title) + const el = container.querySelector('[data-slot="card-title"]') + expect(el?.className).toContain('title-custom') + }) + + it('has font-medium class by default', () => { + const { container } = render(Title) + const el = container.querySelector('[data-slot="card-title"]') + expect(el?.className).toContain('font-medium') + }) +}) + +// ─── CardDescription ────────────────────────────────────────────────────────── + +describe('CardDescription', () => { + it('has data-slot="card-description"', () => { + const { container } = render(desc) + const el = container.querySelector('[data-slot="card-description"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render(Some description) + expect(screen.getByText('Some description')).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(desc) + const el = container.querySelector('[data-slot="card-description"]') + expect(el?.className).toContain('desc-cls') + }) + + it('has text-muted-foreground class by default', () => { + const { container } = render(desc) + const el = container.querySelector('[data-slot="card-description"]') + expect(el?.className).toContain('text-muted-foreground') + }) +}) + +// ─── CardAction ─────────────────────────────────────────────────────────────── + +describe('CardAction', () => { + it('has data-slot="card-action"', () => { + const { container } = render(action) + const el = container.querySelector('[data-slot="card-action"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render() + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(action) + const el = container.querySelector('[data-slot="card-action"]') + expect(el?.className).toContain('action-cls') + }) + + it('has col-start-2 layout class by default', () => { + const { container } = render(action) + const el = container.querySelector('[data-slot="card-action"]') + expect(el?.className).toContain('col-start-2') + }) +}) + +// ─── CardContent ────────────────────────────────────────────────────────────── + +describe('CardContent', () => { + it('has data-slot="card-content"', () => { + const { container } = render(body) + const el = container.querySelector('[data-slot="card-content"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render(Card Body Text) + expect(screen.getByText('Card Body Text')).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(body) + const el = container.querySelector('[data-slot="card-content"]') + expect(el?.className).toContain('body-cls') + }) + + it('passes arbitrary HTML attributes through', () => { + const { container } = render(body) + const el = container.querySelector('[data-slot="card-content"]') + expect(el).toHaveAttribute('data-testid', 'card-body') + }) +}) + +// ─── CardFooter ─────────────────────────────────────────────────────────────── + +describe('CardFooter', () => { + it('has data-slot="card-footer"', () => { + const { container } = render(footer) + const el = container.querySelector('[data-slot="card-footer"]') + expect(el).toBeInTheDocument() + }) + + it('renders children', () => { + render(Footer Text) + expect(screen.getByText('Footer Text')).toBeInTheDocument() + }) + + it('merges a custom className', () => { + const { container } = render(footer) + const el = container.querySelector('[data-slot="card-footer"]') + expect(el?.className).toContain('footer-cls') + }) + + it('has border-t class by default', () => { + const { container } = render(footer) + const el = container.querySelector('[data-slot="card-footer"]') + expect(el?.className).toContain('border-t') + }) + + it('has rounded-b-xl class by default', () => { + const { container } = render(footer) + const el = container.querySelector('[data-slot="card-footer"]') + expect(el?.className).toContain('rounded-b-xl') + }) + + it('passes arbitrary HTML attributes through', () => { + const { container } = render(footer) + const el = container.querySelector('[data-slot="card-footer"]') + expect(el).toHaveAttribute('role', 'contentinfo') + }) +}) + +// ─── Composed Card ─────────────────────────────────────────────────────────── + +describe('Composed Card', () => { + it('renders a full card with all sub-components', () => { + render( + + + Stream to Alice + Monthly salary + + + 100 USDC / month + Ends Jan 2026 + , + ) + + expect(screen.getByText('Stream to Alice')).toBeInTheDocument() + expect(screen.getByText('Monthly salary')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + expect(screen.getByText('100 USDC / month')).toBeInTheDocument() + expect(screen.getByText('Ends Jan 2026')).toBeInTheDocument() + }) + + it('each sub-component carries the correct data-slot in a composed tree', () => { + const { container } = render( + + + Title + + Body + Footer + , + ) + + expect(container.querySelector('[data-slot="card"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="card-header"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="card-title"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="card-content"]')).toBeInTheDocument() + expect(container.querySelector('[data-slot="card-footer"]')).toBeInTheDocument() + }) +})