From 20dc3a0550d80650f096980c03133d4df2bb7f15 Mon Sep 17 00:00:00 2001
From: GenesisPray <125413943+GenesisPray@users.noreply.github.com>
Date: Tue, 25 Aug 2026 11:12:48 +0000
Subject: [PATCH] test: cover theme toggle and correct docs
---
__tests__/components/theme-toggle.test.tsx | 61 ++++++++++++++++------
components/ThemeToggle.tsx | 2 +-
docs/adr/ADR-003-mock-mode.md | 4 +-
docs/cli-examples.md | 34 ++++--------
lib/export.ts | 5 ++
5 files changed, 64 insertions(+), 42 deletions(-)
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)