From 7f66ce9ce85aa5a3f763df01fcc6c3b0d4f6a45e Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Tue, 21 Apr 2026 22:06:41 +0100 Subject: [PATCH 1/7] feat: implement useWalletJourneyState hook for wallet state management --- src/hooks/useWalletJourneyState.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/hooks/useWalletJourneyState.ts diff --git a/src/hooks/useWalletJourneyState.ts b/src/hooks/useWalletJourneyState.ts new file mode 100644 index 000000000..5535d0f78 --- /dev/null +++ b/src/hooks/useWalletJourneyState.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react' +import { useStore } from 'zustand' +import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' +import { jmSessionStore } from '@/store/jmSessionStore' + +export type WalletJourneyState = + | 'no_wallet_or_not_initialized' + | 'coinjoin_in_progress' + | 'empty_wallet' + | 'ready_for_coinjoin' + | 'funded_not_ready' + +type ServiceInfoContextValue = { + serviceAvailable: boolean + makerRunning: boolean + coinjoinInProgress: boolean +} + +const useServiceInfoContext = (): ServiceInfoContextValue => { + const jmSession = useStore(jmSessionStore, (state) => state.state) + + return useMemo( + () => ({ + serviceAvailable: jmSession !== undefined, + makerRunning: jmSession?.maker_running === true, + coinjoinInProgress: jmSession?.coinjoin_in_process === true || (jmSession?.schedule?.length || 0) > 0, + }), + [jmSession], + ) +} + +export function useWalletJourneyState(): WalletJourneyState { + const wallet = useJamWalletInfoContext() + const serviceInfo = useServiceInfoContext() + + return useMemo(() => { + const walletReady = wallet.isLoading === false && wallet.error === null + const hasWallet = wallet.walletName !== null + + const totalBalanceSats = wallet.walletBalanceSummary.calculatedTotalBalanceInSats + const availableBalanceSats = wallet.walletBalanceSummary.calculatedAvailableBalanceInSats + + if (!hasWallet || !walletReady || !serviceInfo.serviceAvailable) { + return 'no_wallet_or_not_initialized' + } + + if (serviceInfo.coinjoinInProgress) { + return 'coinjoin_in_progress' + } + + if (totalBalanceSats <= 0) { + return 'empty_wallet' + } + + if (!serviceInfo.makerRunning && availableBalanceSats > 0) { + return 'ready_for_coinjoin' + } + + return 'funded_not_ready' + }, [ + serviceInfo.coinjoinInProgress, + serviceInfo.makerRunning, + serviceInfo.serviceAvailable, + wallet.error, + wallet.isLoading, + wallet.walletBalanceSummary.calculatedAvailableBalanceInSats, + wallet.walletBalanceSummary.calculatedTotalBalanceInSats, + wallet.walletName, + ]) +} From dc265cbbb3448208cf676298f9c1653d8e71dc5c Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Tue, 21 Apr 2026 22:37:07 +0100 Subject: [PATCH 2/7] feat: integrate useWalletJourneyState hook into MainWalletPage for enhanced wallet state management --- src/components/MainWalletPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 0d4b511e4..b8c30af00 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -8,6 +8,7 @@ import { ClickableJar } from '@/components/ui/jam/ClickableJar' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { routes } from '@/constants/routes' import { useJamDisplayContext } from '@/context/JamDisplayContext' +import { useWalletJourneyState } from '@/hooks/useWalletJourneyState' import { useJamWalletInfoContext, useJars, @@ -31,6 +32,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false) const { toggleDisplayMode } = useJamDisplayContext() + const journeyState = useWalletJourneyState() const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext() const { walletBalanceSummary } = useWalletBalanceSummary() const { jars } = useJars() @@ -54,7 +56,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) walletFileName={walletFileName} selectedJarIndex={selectedJar?.jarIndex} /> -
+

{walletNameTitle} From a5c743c8b44ba5b4d713919a3a93d55b7a808590 Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Tue, 21 Apr 2026 22:46:48 +0100 Subject: [PATCH 3/7] test(wallet): add RTL tests for journey state attribute --- src/components/MainWalletPage.test.tsx | 125 +++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/components/MainWalletPage.test.tsx diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx new file mode 100644 index 000000000..5d2174aee --- /dev/null +++ b/src/components/MainWalletPage.test.tsx @@ -0,0 +1,125 @@ +import '@testing-library/jest-dom/vitest' +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MainWalletPage from '@/components/MainWalletPage' + +type WalletContextMock = { + walletName: string | null + isLoading: boolean + isFetching: boolean + error: Error | null + walletBalanceSummary: { + calculatedTotalBalanceInSats: number + calculatedAvailableBalanceInSats: number + calculatedFrozenOrLockedBalanceInSats: number + } +} + +type ServiceInfoContextMock = { + maker_running?: boolean + coinjoin_in_process?: boolean + schedule?: unknown[] +} | undefined + +const walletContextMock: WalletContextMock = { + walletName: 'Test Wallet', + isLoading: false, + isFetching: false, + error: null, + walletBalanceSummary: { + calculatedTotalBalanceInSats: 100_000, + calculatedAvailableBalanceInSats: 100_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, +} + +let serviceInfoContextMock: ServiceInfoContextMock = undefined + +const setWalletContextMock = (overrides: Partial = {}) => { + Object.assign(walletContextMock, overrides) +} + +const setServiceInfoContextMock = (value: ServiceInfoContextMock) => { + serviceInfoContextMock = value +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('react-router-dom', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('zustand', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useStore: (_store: unknown, selector: (state: { state: ServiceInfoContextMock }) => unknown) => { + return selector({ state: serviceInfoContextMock }) + }, + } +}) + +vi.mock('@/context/JamDisplayContext', () => ({ + useJamDisplayContext: () => ({ toggleDisplayMode: vi.fn() }), +})) + +vi.mock('@/context/JamWalletInfoContext', () => ({ + useJamWalletInfoContext: () => ({ + walletName: walletContextMock.walletName, + isLoading: walletContextMock.isLoading, + isFetching: walletContextMock.isFetching, + error: walletContextMock.error, + refetch: vi.fn(), + walletBalanceSummary: walletContextMock.walletBalanceSummary, + }), + useWalletBalanceSummary: () => ({ + walletBalanceSummary: walletContextMock.walletBalanceSummary, + isLoading: walletContextMock.isLoading, + }), + useJars: () => ({ jars: [], isLoading: false }), +})) + +vi.mock('@/components/wallet/WalletJarsDetailsOverlay', () => ({ + WalletJarsDetailsOverlay: () => null, +})) + +describe(' journey state', () => { + beforeEach(() => { + setWalletContextMock({ + walletName: 'Test Wallet', + isLoading: false, + isFetching: false, + error: null, + walletBalanceSummary: { + calculatedTotalBalanceInSats: 100_000, + calculatedAvailableBalanceInSats: 100_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock(undefined) + }) + + it('sets data-journey-state to no_wallet_or_not_initialized when service is unavailable', () => { + setServiceInfoContextMock(undefined) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'no_wallet_or_not_initialized') + }) + + it('sets data-journey-state to coinjoin_in_progress when coinjoin is active', () => { + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: true, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'coinjoin_in_progress') + }) +}) From 575653ce566dcae20634c76490c0032ec32695e2 Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Tue, 21 Apr 2026 22:53:27 +0100 Subject: [PATCH 4/7] feat: add Wallet-State Friction Audit documentation outlining user experience issues and suggested improvements --- docs/wallet-state-friction-audit.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/wallet-state-friction-audit.md diff --git a/docs/wallet-state-friction-audit.md b/docs/wallet-state-friction-audit.md new file mode 100644 index 000000000..5238ff90e --- /dev/null +++ b/docs/wallet-state-friction-audit.md @@ -0,0 +1,25 @@ +# Wallet-State Friction Audit + +## Scope +Routes reviewed for silent stuck states and unclear recovery paths: +- `/` +- `/send` +- `/sweep` +- `/earn` + +## Findings + +| Route | Problem | Why it blocks users | Suggested improvement | +|---|---|---|---| +| `/send` | Silent form lock when maker is running or wallet is rescanning | The Send form is disabled by state gate logic, but there is no dedicated in-page reason for maker/rescan lock on this route. Users can see controls but cannot proceed, without a clear unblock action. | Add explicit blocking alerts for maker-running and rescanning on Send (same style as existing coinjoin warning), with direct CTA links to `/earn` (stop maker) or `/settings/rescan` status. | +| `/send` | Fee configuration blocker is only fully enforced at coinjoin submit time | Fee missing is shown as a banner, but coinjoin flow is still interactive until submit, then hard-blocks and opens config dialog. This creates a late failure moment. | Make coinjoin path pre-disabled when fee config is missing and show a single inline "Configure fee limits to continue" state near the submit button (keep existing dialog CTA). | +| `/send` | Coinjoin preconditions warning allows "send despite warning" without guided recovery | Preconditions render as warning text and the primary action remains available as "send despite warning", so users may keep retrying instead of resolving root cause (confirmations, missing UTXOs, retry locks). | Add contextual next-step CTA in warning block: "Deposit to source jar", "Wait for confirmations", or "Switch source jar", and only show "send despite warning" for non-blocking warning classes. | +| `/sweep` | Start action can be silently disabled during rescanning | Sweep computes `isOperationDisabled` with rescanning and preconditions, but only maker/coinjoin cases are explicitly alerted. Rescan lock lacks a direct explanatory alert on this page. | Add a rescanning warning alert above controls with a progress/status link to `/settings/rescan`, and include reason text beside disabled start button. | +| `/sweep` | Multi-factor disable state obscures single next step | Start can be disabled by fee config, collaborative activity, preconditions, destination validation, waiting state; user sees a disabled button but may not know which condition is primary. | Add a compact "blocking checklist" summary (first unmet condition only) directly above the Start button, with one actionable instruction at a time. | +| `/earn` | Earn form disabled for coinjoin/rescan without explicit route-level explanation | Form disable logic includes `coinjoin_in_process` and `rescanning`, but route alerts focus on maker state and offer loading; users may encounter disabled form without clear remediation. | Add explicit alerts for "Coinjoin in progress" and "Rescanning in progress" on Earn, with direct guidance ("stop/finish coinjoin first" or "wait for rescan completion"). | +| `/` | Empty wallet state is implicit, not explicit | Zero balance is rendered as a normal value; users must infer they need to fund first. There is no dedicated empty-state explanation or guided path. | Add explicit empty-wallet callout when total balance is zero, with a primary CTA to Receive and a secondary "How coinjoin readiness works" hint. | + +## Priority +1. Add explicit reason banners for disabled Send/Earn/Sweep actions (maker, rescanning, active coinjoin). +2. Convert late fee-config failure on Send into an early inline blocker with direct settings action. +3. Add empty-wallet explicit state on Main Wallet with clear first-step CTA. From 64980b1181ba4ce496e81beab9c7f04ccf6577ed Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Tue, 21 Apr 2026 22:55:47 +0100 Subject: [PATCH 5/7] docs: enhance developer documentation for simulating wallet journey states in regtest --- docs/developing.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/developing.md b/docs/developing.md index 11b72e17f..4f96c5326 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -8,6 +8,74 @@ A place to collect useful information for developers that doesn't really fit els For a complete development environment you need a local JoinMarket instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md). +## Simulating Wallet Journey States (Regtest) + +Use these shortcuts when testing wallet-state driven UI flows. + +### Setup + +```sh +npm run regtest:up +npm run dev +``` + +### Simulate Empty Wallet + +Use a fresh environment and do not run initialization funding. + +```sh +npm run regtest:down +npm run regtest:clear +npm run regtest:up +# do NOT run: npm run regtest:init +``` + +Expected result in UI: wallet opens with zero balance and no spendable funds. + +### Simulate Funded Wallet + +Fund wallets using the bundled init flow: + +```sh +npm run regtest:init +``` + +This funds wallets and starts makers in secondary/tertiary containers. + +Expected result in UI: non-zero balance and selectable source jars with available balance. + +### Simulate CoinJoin-Ready State + +CoinJoin-ready in UI typically means: +- wallet funded with available (not frozen/locked) UTXOs +- enough confirmations (default precondition: 5) +- no active maker/coinjoin/rescan conflict for the action you test + +Practical flow: + +```sh +npm run regtest:init +npm run regtest:mine +``` + +Then open Send and select a funded source jar. Preconditions warning should disappear once confirmations/eligibility are satisfied. + +### Verify UI State Changes + +- Main wallet page exposes a journey marker: inspect `[data-journey-state]` in browser devtools. +- Common values: `no_wallet_or_not_initialized`, `empty_wallet`, `ready_for_coinjoin`, `coinjoin_in_progress`, `funded_not_ready`. +- Cross-check with route behavior: + - disabled/enabled actions on Send, Earn, Sweep + - precondition alerts on Send/Sweep + - fee-config blocker banner and dialog CTA + +### Contributor Tips + +- Keep `npm run regtest:mine` running while testing confirmation-dependent flows. +- If UI seems stuck, check for maker running / coinjoin running / rescanning state first. +- For fee-related blockers, use the Settings fee dialog from the in-page alert instead of retrying submits. +- Use `npm run regtest:clear` when state gets hard to reason about. + ## Linting We use Create React App's [default ESLint integration](https://create-react-app.dev/docs/setting-up-your-editor/#displaying-lint-output-in-the-editor). From f2937bd133557a44c8aba87b24d6a06d63a16f3f Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Wed, 22 Apr 2026 02:23:12 +0100 Subject: [PATCH 6/7] fix: update journey state handling in MainWalletPage and enhance documentation for wallet states --- docs/developing.md | 1 + docs/wallet-state-friction-audit.md | 21 ++++---- src/components/MainWalletPage.test.tsx | 72 ++++++++++++++++++++++++-- src/components/MainWalletPage.tsx | 2 +- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs/developing.md b/docs/developing.md index 4f96c5326..b96f4b9dc 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -47,6 +47,7 @@ Expected result in UI: non-zero balance and selectable source jars with availabl ### Simulate CoinJoin-Ready State CoinJoin-ready in UI typically means: + - wallet funded with available (not frozen/locked) UTXOs - enough confirmations (default precondition: 5) - no active maker/coinjoin/rescan conflict for the action you test diff --git a/docs/wallet-state-friction-audit.md b/docs/wallet-state-friction-audit.md index 5238ff90e..1219a6cb5 100644 --- a/docs/wallet-state-friction-audit.md +++ b/docs/wallet-state-friction-audit.md @@ -1,7 +1,9 @@ # Wallet-State Friction Audit ## Scope + Routes reviewed for silent stuck states and unclear recovery paths: + - `/` - `/send` - `/sweep` @@ -9,17 +11,18 @@ Routes reviewed for silent stuck states and unclear recovery paths: ## Findings -| Route | Problem | Why it blocks users | Suggested improvement | -|---|---|---|---| -| `/send` | Silent form lock when maker is running or wallet is rescanning | The Send form is disabled by state gate logic, but there is no dedicated in-page reason for maker/rescan lock on this route. Users can see controls but cannot proceed, without a clear unblock action. | Add explicit blocking alerts for maker-running and rescanning on Send (same style as existing coinjoin warning), with direct CTA links to `/earn` (stop maker) or `/settings/rescan` status. | -| `/send` | Fee configuration blocker is only fully enforced at coinjoin submit time | Fee missing is shown as a banner, but coinjoin flow is still interactive until submit, then hard-blocks and opens config dialog. This creates a late failure moment. | Make coinjoin path pre-disabled when fee config is missing and show a single inline "Configure fee limits to continue" state near the submit button (keep existing dialog CTA). | -| `/send` | Coinjoin preconditions warning allows "send despite warning" without guided recovery | Preconditions render as warning text and the primary action remains available as "send despite warning", so users may keep retrying instead of resolving root cause (confirmations, missing UTXOs, retry locks). | Add contextual next-step CTA in warning block: "Deposit to source jar", "Wait for confirmations", or "Switch source jar", and only show "send despite warning" for non-blocking warning classes. | -| `/sweep` | Start action can be silently disabled during rescanning | Sweep computes `isOperationDisabled` with rescanning and preconditions, but only maker/coinjoin cases are explicitly alerted. Rescan lock lacks a direct explanatory alert on this page. | Add a rescanning warning alert above controls with a progress/status link to `/settings/rescan`, and include reason text beside disabled start button. | -| `/sweep` | Multi-factor disable state obscures single next step | Start can be disabled by fee config, collaborative activity, preconditions, destination validation, waiting state; user sees a disabled button but may not know which condition is primary. | Add a compact "blocking checklist" summary (first unmet condition only) directly above the Start button, with one actionable instruction at a time. | -| `/earn` | Earn form disabled for coinjoin/rescan without explicit route-level explanation | Form disable logic includes `coinjoin_in_process` and `rescanning`, but route alerts focus on maker state and offer loading; users may encounter disabled form without clear remediation. | Add explicit alerts for "Coinjoin in progress" and "Rescanning in progress" on Earn, with direct guidance ("stop/finish coinjoin first" or "wait for rescan completion"). | -| `/` | Empty wallet state is implicit, not explicit | Zero balance is rendered as a normal value; users must infer they need to fund first. There is no dedicated empty-state explanation or guided path. | Add explicit empty-wallet callout when total balance is zero, with a primary CTA to Receive and a secondary "How coinjoin readiness works" hint. | +| Route | Problem | Why it blocks users | Suggested improvement | +| -------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/send` | Silent form lock when maker is running or wallet is rescanning | The Send form is disabled by state gate logic, but there is no dedicated in-page reason for maker/rescan lock on this route. Users can see controls but cannot proceed, without a clear unblock action. | Add explicit blocking alerts for maker-running and rescanning on Send (same style as existing coinjoin warning), with direct CTA links to `/earn` (stop maker) or `/settings/rescan` status. | +| `/send` | Fee configuration blocker is only fully enforced at coinjoin submit time | Fee missing is shown as a banner, but coinjoin flow is still interactive until submit, then hard-blocks and opens config dialog. This creates a late failure moment. | Make coinjoin path pre-disabled when fee config is missing and show a single inline "Configure fee limits to continue" state near the submit button (keep existing dialog CTA). | +| `/send` | Coinjoin preconditions warning allows "send despite warning" without guided recovery | Preconditions render as warning text and the primary action remains available as "send despite warning", so users may keep retrying instead of resolving root cause (confirmations, missing UTXOs, retry locks). | Add contextual next-step CTA in warning block: "Deposit to source jar", "Wait for confirmations", or "Switch source jar", and only show "send despite warning" for non-blocking warning classes. | +| `/sweep` | Start action can be silently disabled during rescanning | Sweep computes `isOperationDisabled` with rescanning and preconditions, but only maker/coinjoin cases are explicitly alerted. Rescan lock lacks a direct explanatory alert on this page. | Add a rescanning warning alert above controls with a progress/status link to `/settings/rescan`, and include reason text beside disabled start button. | +| `/sweep` | Multi-factor disable state obscures single next step | Start can be disabled by fee config, collaborative activity, preconditions, destination validation, waiting state; user sees a disabled button but may not know which condition is primary. | Add a compact "blocking checklist" summary (first unmet condition only) directly above the Start button, with one actionable instruction at a time. | +| `/earn` | Earn form disabled for coinjoin/rescan without explicit route-level explanation | Form disable logic includes `coinjoin_in_process` and `rescanning`, but route alerts focus on maker state and offer loading; users may encounter disabled form without clear remediation. | Add explicit alerts for "Coinjoin in progress" and "Rescanning in progress" on Earn, with direct guidance ("stop/finish coinjoin first" or "wait for rescan completion"). | +| `/` | Empty wallet state is implicit, not explicit | Zero balance is rendered as a normal value; users must infer they need to fund first. There is no dedicated empty-state explanation or guided path. | Add explicit empty-wallet callout when total balance is zero, with a primary CTA to Receive and a secondary "How coinjoin readiness works" hint. | ## Priority + 1. Add explicit reason banners for disabled Send/Earn/Sweep actions (maker, rescanning, active coinjoin). 2. Convert late fee-config failure on Send into an early inline blocker with direct settings action. 3. Add empty-wallet explicit state on Main Wallet with clear first-step CTA. diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx index 5d2174aee..5ff1f30b6 100644 --- a/src/components/MainWalletPage.test.tsx +++ b/src/components/MainWalletPage.test.tsx @@ -15,11 +15,13 @@ type WalletContextMock = { } } -type ServiceInfoContextMock = { - maker_running?: boolean - coinjoin_in_process?: boolean - schedule?: unknown[] -} | undefined +type ServiceInfoContextMock = + | { + maker_running?: boolean + coinjoin_in_process?: boolean + schedule?: unknown[] + } + | undefined const walletContextMock: WalletContextMock = { walletName: 'Test Wallet', @@ -122,4 +124,64 @@ describe(' journey state', () => { expect(journeyContainer).toHaveAttribute('data-journey-state', 'coinjoin_in_progress') }) + + it('sets data-journey-state to empty_wallet when balance is zero', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 0, + calculatedAvailableBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'empty_wallet') + }) + + it('sets data-journey-state to ready_for_coinjoin when service is available and funds are spendable', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 250_000, + calculatedAvailableBalanceInSats: 250_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'ready_for_coinjoin') + }) + + it('sets data-journey-state to funded_not_ready when maker is running', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 250_000, + calculatedAvailableBalanceInSats: 250_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: true, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'funded_not_ready') + }) }) diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index b8c30af00..d98825030 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -8,13 +8,13 @@ import { ClickableJar } from '@/components/ui/jam/ClickableJar' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { routes } from '@/constants/routes' import { useJamDisplayContext } from '@/context/JamDisplayContext' -import { useWalletJourneyState } from '@/hooks/useWalletJourneyState' import { useJamWalletInfoContext, useJars, useWalletBalanceSummary, type Jar as JarObject, } from '@/context/JamWalletInfoContext' +import { useWalletJourneyState } from '@/hooks/useWalletJourneyState' import type { WalletFileName } from '@/lib/utils' import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils' import { Balance } from './ui/jam/Balance' From 7e5930e8dd58582ecacf42cabfeb4644b887e948 Mon Sep 17 00:00:00 2001 From: Bluuefanatic Date: Wed, 22 Apr 2026 02:24:44 +0100 Subject: [PATCH 7/7] refactor(tests): improve formatting and structure of MainWalletPage tests for better readability --- src/components/MainWalletPage.test.tsx | 262 ++++++++++++------------- 1 file changed, 131 insertions(+), 131 deletions(-) diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx index 5ff1f30b6..d8abd8be6 100644 --- a/src/components/MainWalletPage.test.tsx +++ b/src/components/MainWalletPage.test.tsx @@ -4,184 +4,184 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import MainWalletPage from '@/components/MainWalletPage' type WalletContextMock = { - walletName: string | null - isLoading: boolean - isFetching: boolean - error: Error | null - walletBalanceSummary: { - calculatedTotalBalanceInSats: number - calculatedAvailableBalanceInSats: number - calculatedFrozenOrLockedBalanceInSats: number - } + walletName: string | null + isLoading: boolean + isFetching: boolean + error: Error | null + walletBalanceSummary: { + calculatedTotalBalanceInSats: number + calculatedAvailableBalanceInSats: number + calculatedFrozenOrLockedBalanceInSats: number + } } type ServiceInfoContextMock = - | { - maker_running?: boolean - coinjoin_in_process?: boolean - schedule?: unknown[] + | { + maker_running?: boolean + coinjoin_in_process?: boolean + schedule?: unknown[] } - | undefined + | undefined const walletContextMock: WalletContextMock = { - walletName: 'Test Wallet', - isLoading: false, - isFetching: false, - error: null, - walletBalanceSummary: { - calculatedTotalBalanceInSats: 100_000, - calculatedAvailableBalanceInSats: 100_000, - calculatedFrozenOrLockedBalanceInSats: 0, - }, + walletName: 'Test Wallet', + isLoading: false, + isFetching: false, + error: null, + walletBalanceSummary: { + calculatedTotalBalanceInSats: 100_000, + calculatedAvailableBalanceInSats: 100_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, } let serviceInfoContextMock: ServiceInfoContextMock = undefined const setWalletContextMock = (overrides: Partial = {}) => { - Object.assign(walletContextMock, overrides) + Object.assign(walletContextMock, overrides) } const setServiceInfoContextMock = (value: ServiceInfoContextMock) => { - serviceInfoContextMock = value + serviceInfoContextMock = value } vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ t: (key: string) => key }), })) vi.mock('react-router-dom', () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => vi.fn(), })) vi.mock('zustand', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useStore: (_store: unknown, selector: (state: { state: ServiceInfoContextMock }) => unknown) => { - return selector({ state: serviceInfoContextMock }) - }, - } + const actual = await importOriginal() + return { + ...actual, + useStore: (_store: unknown, selector: (state: { state: ServiceInfoContextMock }) => unknown) => { + return selector({ state: serviceInfoContextMock }) + }, + } }) vi.mock('@/context/JamDisplayContext', () => ({ - useJamDisplayContext: () => ({ toggleDisplayMode: vi.fn() }), + useJamDisplayContext: () => ({ toggleDisplayMode: vi.fn() }), })) vi.mock('@/context/JamWalletInfoContext', () => ({ - useJamWalletInfoContext: () => ({ - walletName: walletContextMock.walletName, - isLoading: walletContextMock.isLoading, - isFetching: walletContextMock.isFetching, - error: walletContextMock.error, - refetch: vi.fn(), - walletBalanceSummary: walletContextMock.walletBalanceSummary, - }), - useWalletBalanceSummary: () => ({ - walletBalanceSummary: walletContextMock.walletBalanceSummary, - isLoading: walletContextMock.isLoading, - }), - useJars: () => ({ jars: [], isLoading: false }), + useJamWalletInfoContext: () => ({ + walletName: walletContextMock.walletName, + isLoading: walletContextMock.isLoading, + isFetching: walletContextMock.isFetching, + error: walletContextMock.error, + refetch: vi.fn(), + walletBalanceSummary: walletContextMock.walletBalanceSummary, + }), + useWalletBalanceSummary: () => ({ + walletBalanceSummary: walletContextMock.walletBalanceSummary, + isLoading: walletContextMock.isLoading, + }), + useJars: () => ({ jars: [], isLoading: false }), })) vi.mock('@/components/wallet/WalletJarsDetailsOverlay', () => ({ - WalletJarsDetailsOverlay: () => null, + WalletJarsDetailsOverlay: () => null, })) describe(' journey state', () => { - beforeEach(() => { - setWalletContextMock({ - walletName: 'Test Wallet', - isLoading: false, - isFetching: false, - error: null, - walletBalanceSummary: { - calculatedTotalBalanceInSats: 100_000, - calculatedAvailableBalanceInSats: 100_000, - calculatedFrozenOrLockedBalanceInSats: 0, - }, + beforeEach(() => { + setWalletContextMock({ + walletName: 'Test Wallet', + isLoading: false, + isFetching: false, + error: null, + walletBalanceSummary: { + calculatedTotalBalanceInSats: 100_000, + calculatedAvailableBalanceInSats: 100_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock(undefined) }) - setServiceInfoContextMock(undefined) - }) - - it('sets data-journey-state to no_wallet_or_not_initialized when service is unavailable', () => { - setServiceInfoContextMock(undefined) - const { container } = render() - const journeyContainer = container.querySelector('[data-journey-state]') + it('sets data-journey-state to no_wallet_or_not_initialized when service is unavailable', () => { + setServiceInfoContextMock(undefined) - expect(journeyContainer).toHaveAttribute('data-journey-state', 'no_wallet_or_not_initialized') - }) + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') - it('sets data-journey-state to coinjoin_in_progress when coinjoin is active', () => { - setServiceInfoContextMock({ - maker_running: false, - coinjoin_in_process: true, - schedule: [], + expect(journeyContainer).toHaveAttribute('data-journey-state', 'no_wallet_or_not_initialized') }) - const { container } = render() - const journeyContainer = container.querySelector('[data-journey-state]') + it('sets data-journey-state to coinjoin_in_progress when coinjoin is active', () => { + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: true, + schedule: [], + }) - expect(journeyContainer).toHaveAttribute('data-journey-state', 'coinjoin_in_progress') - }) + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') - it('sets data-journey-state to empty_wallet when balance is zero', () => { - setWalletContextMock({ - walletBalanceSummary: { - calculatedTotalBalanceInSats: 0, - calculatedAvailableBalanceInSats: 0, - calculatedFrozenOrLockedBalanceInSats: 0, - }, + expect(journeyContainer).toHaveAttribute('data-journey-state', 'coinjoin_in_progress') }) - setServiceInfoContextMock({ - maker_running: false, - coinjoin_in_process: false, - schedule: [], - }) - - const { container } = render() - const journeyContainer = container.querySelector('[data-journey-state]') - - expect(journeyContainer).toHaveAttribute('data-journey-state', 'empty_wallet') - }) - it('sets data-journey-state to ready_for_coinjoin when service is available and funds are spendable', () => { - setWalletContextMock({ - walletBalanceSummary: { - calculatedTotalBalanceInSats: 250_000, - calculatedAvailableBalanceInSats: 250_000, - calculatedFrozenOrLockedBalanceInSats: 0, - }, - }) - setServiceInfoContextMock({ - maker_running: false, - coinjoin_in_process: false, - schedule: [], + it('sets data-journey-state to empty_wallet when balance is zero', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 0, + calculatedAvailableBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'empty_wallet') }) - const { container } = render() - const journeyContainer = container.querySelector('[data-journey-state]') - - expect(journeyContainer).toHaveAttribute('data-journey-state', 'ready_for_coinjoin') - }) - - it('sets data-journey-state to funded_not_ready when maker is running', () => { - setWalletContextMock({ - walletBalanceSummary: { - calculatedTotalBalanceInSats: 250_000, - calculatedAvailableBalanceInSats: 250_000, - calculatedFrozenOrLockedBalanceInSats: 0, - }, - }) - setServiceInfoContextMock({ - maker_running: true, - coinjoin_in_process: false, - schedule: [], + it('sets data-journey-state to ready_for_coinjoin when service is available and funds are spendable', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 250_000, + calculatedAvailableBalanceInSats: 250_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: false, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'ready_for_coinjoin') }) - const { container } = render() - const journeyContainer = container.querySelector('[data-journey-state]') - - expect(journeyContainer).toHaveAttribute('data-journey-state', 'funded_not_ready') - }) + it('sets data-journey-state to funded_not_ready when maker is running', () => { + setWalletContextMock({ + walletBalanceSummary: { + calculatedTotalBalanceInSats: 250_000, + calculatedAvailableBalanceInSats: 250_000, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + }) + setServiceInfoContextMock({ + maker_running: true, + coinjoin_in_process: false, + schedule: [], + }) + + const { container } = render() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'funded_not_ready') + }) })