diff --git a/__tests__/components/theme-toggle.test.tsx b/__tests__/components/theme-toggle.test.tsx index 0f94f65..78e3e72 100644 --- a/__tests__/components/theme-toggle.test.tsx +++ b/__tests__/components/theme-toggle.test.tsx @@ -1,30 +1,59 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { renderToString } from 'react-dom/server' import { ThemeToggle } from '@/components/ThemeToggle' -const mockSetTheme = vi.fn() -let currentTheme = 'system' +const mockUseTheme = vi.fn() vi.mock('next-themes', () => ({ - useTheme: () => ({ - theme: currentTheme, - setTheme: mockSetTheme, - }), + useTheme: () => mockUseTheme(), })) describe('ThemeToggle', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('renders nothing before mount', () => { + mockUseTheme.mockReturnValue({ theme: 'light', setTheme: vi.fn() }) + + expect(renderToString()).toBe('') + }) + + it('cycles light, dark, system, and light with the current theme label', async () => { + let theme = 'light' + const setTheme = vi.fn((nextTheme: string) => { + theme = nextTheme + }) + mockUseTheme.mockImplementation(() => ({ theme, setTheme })) + + const view = render() + const button = await screen.findByRole('button') + + expect(button).toHaveAttribute('aria-label', 'Current theme: light. Click to switch.') + + button.click() + expect(setTheme).toHaveBeenLastCalledWith('dark') + view.rerender() + expect(button).toHaveAttribute('aria-label', 'Current theme: dark. Click to switch.') + + button.click() + expect(setTheme).toHaveBeenLastCalledWith('system') + view.rerender() + expect(button).toHaveAttribute('aria-label', 'Current theme: system. Click to switch.') + + button.click() + expect(setTheme).toHaveBeenLastCalledWith('light') + view.rerender() + expect(button).toHaveAttribute('aria-label', 'Current theme: light. Click to switch.') + }) + it('renders a button with type="button"', () => { + mockUseTheme.mockReturnValue({ theme: 'system', setTheme: vi.fn() }) + render() const button = screen.getByRole('button') expect(button).toBeDefined() expect(button.getAttribute('type')).toBe('button') }) - - it('cycles through theme options on click', () => { - currentTheme = 'system' - render() - const button = screen.getByRole('button') - fireEvent.click(button) - expect(mockSetTheme).toHaveBeenCalledWith('light') - }) }) diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index fb015f5..925a6ce 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -12,7 +12,7 @@ export function ThemeToggle() { // Avoid hydration mismatch — only render after mount useEffect(() => setMounted(true), []) - if (!mounted) return
+ if (!mounted) return null const cycle = () => { const idx = MODES.indexOf((theme as (typeof MODES)[number]) ?? 'system') diff --git a/docs/adr/ADR-003-mock-mode.md b/docs/adr/ADR-003-mock-mode.md index dc3759e..388bbaf 100644 --- a/docs/adr/ADR-003-mock-mode.md +++ b/docs/adr/ADR-003-mock-mode.md @@ -10,9 +10,9 @@ Developing against the live Soroban testnet requires a funded Freighter wallet, ## Decision -The app supports a **mock contract layer** controlled by the `USE_MOCK` flag in `lib/contract.ts`. When enabled, all contract calls are intercepted and served from an in-memory store in `lib/mock-data.ts`. The mock layer implements the same TypeScript interface as the real contract integration so the rest of the app is unaware of the substitution. +The app supports a **mock contract layer** that is automatically selected when the active network has no `NEXT_PUBLIC_STREAM_CONTRACT_ID_{NETWORK}` environment variable configured, as determined in `lib/contract.ts`. When no contract ID is set, contract calls are served from an in-memory store in `lib/mock-data.ts`. The mock layer implements the same TypeScript interface as the real contract integration so the rest of the app is unaware of the substitution. -Mock mode is the default in development (`NODE_ENV === 'development'`) and is always disabled in production builds. +Mock mode is active whenever the selected network has no contract ID configured, regardless of environment. ## Consequences diff --git a/docs/cli-examples.md b/docs/cli-examples.md index aa5689f..9be7e37 100644 --- a/docs/cli-examples.md +++ b/docs/cli-examples.md @@ -27,13 +27,7 @@ soroban contract invoke \ -- \ create_stream \ --sender GXXXXXX... \ - --recipient GYYYYYY... \ - --token CTOKEN... \ - --total_amount 1000000000 \ - --start_time 1700000000 \ - --end_time 1702592000 \ - --cliff_time 1700000000 \ - --cliff_amount 100000000 + --params '{"recipient":"GYYYYYY...","token":"CTOKEN...","total_amount":1000000000,"start_time":1700000000,"end_time":1702592000,"cliff_time":1700000000,"cliff_amount":100000000}' ``` ### 30-Day Monthly Salary Stream @@ -54,13 +48,7 @@ soroban contract invoke \ -- \ create_stream \ --sender $SENDER \ - --recipient $RECIPIENT \ - --token $TOKEN \ - --total_amount $MONTHLY_SALARY \ - --start_time $START \ - --end_time $END \ - --cliff_time $CLIFF \ - --cliff_amount $((MONTHLY_SALARY / 4)) + --params "{\"recipient\":\"$RECIPIENT\",\"token\":\"$TOKEN\",\"total_amount\":$MONTHLY_SALARY,\"start_time\":$START,\"end_time\":$END,\"cliff_time\":$CLIFF,\"cliff_amount\":$((MONTHLY_SALARY / 4))}" ``` ### 1-Year Vesting with 6-Month Cliff @@ -341,7 +329,7 @@ soroban contract invoke \ # Process each row while IFS=',' read -r recipient amount start end cliff cliff_amt; do echo "Creating stream for $recipient..." - + soroban contract invoke \ --id $CONTRACT_ID \ --source $USER_KEYPAIR \ @@ -355,7 +343,7 @@ while IFS=',' read -r recipient amount start end cliff cliff_amt; do --end_time $end \ --cliff_time $cliff \ --cliff_amount $cliff_amt - + # Delay to avoid rate limiting sleep 2 done < streams.csv @@ -370,7 +358,7 @@ WITHDRAWAL_AMOUNT=100000000 for STREAM_ID in "${STREAM_IDS[@]}"; do echo "Withdrawing from stream $STREAM_ID..." - + soroban contract invoke \ --id $CONTRACT_ID \ --source $USER_KEYPAIR \ @@ -378,7 +366,7 @@ for STREAM_ID in "${STREAM_IDS[@]}"; do withdraw \ --stream_id $STREAM_ID \ --amount $WITHDRAWAL_AMOUNT - + sleep 2 done ``` @@ -488,24 +476,24 @@ while true; do echo "=== Stream #$STREAM_ID ===" echo "Updated at: $(date)" echo "" - + STREAM=$(soroban contract invoke \ --id $CONTRACT_ID \ -- \ get_stream \ --stream_id $STREAM_ID) - + echo "$STREAM" | jq '.' - + echo "" WITHDRAWABLE=$(soroban contract invoke \ --id $CONTRACT_ID \ -- \ get_withdrawable \ --stream_id $STREAM_ID) - + echo "Currently withdrawable: $WITHDRAWABLE" - + sleep $INTERVAL done ``` diff --git a/lib/export.ts b/lib/export.ts index 25c0504..d12f2f4 100644 --- a/lib/export.ts +++ b/lib/export.ts @@ -36,6 +36,10 @@ const HEADERS = [ 'Rate (per month)', ] +/** + * Convert streams to CSV with identity, token, amount, date, cliff, and rate columns. + * A zero cliff time or cliff amount is represented by an empty CSV field. + */ export function streamsToCSV( streams: StreamData[], nowSeconds: number = Math.floor(Date.now() / 1000), @@ -78,6 +82,7 @@ export function streamsToCSV( return lines.join('\n') } +/** Download CSV content as a file in the browser. */ export function downloadCSV(csv: string, filename: string): void { const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob)