Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions __tests__/components/theme-toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ThemeToggle />)).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(<ThemeToggle />)
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(<ThemeToggle />)
expect(button).toHaveAttribute('aria-label', 'Current theme: dark. Click to switch.')

button.click()
expect(setTheme).toHaveBeenLastCalledWith('system')
view.rerender(<ThemeToggle />)
expect(button).toHaveAttribute('aria-label', 'Current theme: system. Click to switch.')

button.click()
expect(setTheme).toHaveBeenLastCalledWith('light')
view.rerender(<ThemeToggle />)
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(<ThemeToggle />)
const button = screen.getByRole('button')
expect(button).toBeDefined()
expect(button.getAttribute('type')).toBe('button')
})

it('cycles through theme options on click', () => {
currentTheme = 'system'
render(<ThemeToggle />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(mockSetTheme).toHaveBeenCalledWith('light')
})
})
2 changes: 1 addition & 1 deletion components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function ThemeToggle() {

// Avoid hydration mismatch — only render after mount
useEffect(() => setMounted(true), [])
if (!mounted) return <div className="w-9 h-9" />
if (!mounted) return null

const cycle = () => {
const idx = MODES.indexOf((theme as (typeof MODES)[number]) ?? 'system')
Expand Down
4 changes: 2 additions & 2 deletions docs/adr/ADR-003-mock-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 11 additions & 23 deletions docs/cli-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -370,15 +358,15 @@ 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 \
-- \
withdraw \
--stream_id $STREAM_ID \
--amount $WITHDRAWAL_AMOUNT

sleep 2
done
```
Expand Down Expand Up @@ -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
```
Expand Down
5 changes: 5 additions & 0 deletions lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
Loading