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