@@ -318,6 +318,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
+import BalanceAmount from '@/components/BalanceAmount.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n()
diff --git a/neode-ui/src/components/__tests__/BalanceAmount.test.ts b/neode-ui/src/components/__tests__/BalanceAmount.test.ts
new file mode 100644
index 00000000..0d2a2c99
--- /dev/null
+++ b/neode-ui/src/components/__tests__/BalanceAmount.test.ts
@@ -0,0 +1,119 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import BalanceAmount from '../BalanceAmount.vue'
+import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
+import i18n from '@/i18n'
+
+/**
+ * The distinction this whole component exists to protect: `0` is a balance,
+ * `null` is the absence of one. Rendering the first when you mean the second
+ * tells someone their money is gone, in the wallet's own typeface. Every case
+ * below is really one assertion — that the two never get confused.
+ */
+describe('BalanceAmount', () => {
+ it('shows the pixel readout when the balance is not known yet', () => {
+ const w = mount(BalanceAmount, { props: { sats: null, label: 'on-chain balance' } })
+ expect(w.find('.balance-pixels').exists()).toBe(true)
+ expect(w.text()).not.toContain('0')
+ })
+
+ it('treats undefined the same as null', () => {
+ // Optional props (`arkBalance?.spendable_sats`) arrive as undefined, not
+ // null, and must not fall through to a figure.
+ const w = mount(BalanceAmount, { props: { sats: undefined } })
+ expect(w.find('.balance-pixels').exists()).toBe(true)
+ })
+
+ it('shows a genuine zero as a figure, not as loading', () => {
+ // The inverse mistake: a node that really has no coins must be told so
+ // plainly, not left shimmering forever.
+ const w = mount(BalanceAmount, { props: { sats: 0 } })
+ expect(w.find('.balance-pixels').exists()).toBe(false)
+ expect(w.text()).toBe('0 sats')
+ })
+
+ it('formats a real balance with thousands separators', () => {
+ const w = mount(BalanceAmount, { props: { sats: 9922 } })
+ expect(w.text()).toBe('9,922 sats')
+ })
+
+ it('can drop the unit for bare figures', () => {
+ const w = mount(BalanceAmount, { props: { sats: 21, suffix: '' } })
+ expect(w.text()).toBe('21')
+ })
+
+ it('announces what is loading instead of being silently empty', () => {
+ // A shimmering box with no text is nothing at all to a screen reader.
+ const w = mount(BalanceAmount, { props: { sats: null, label: 'Cashu balance' } })
+ const el = w.find('.balance-pixels')
+ expect(el.attributes('role')).toBe('status')
+ expect(el.attributes('aria-label')).toBe('Loading Cashu balance')
+ })
+
+ it('inherits the rail colour rather than hard-coding one', () => {
+ // The pixels are painted with currentColor, which is what makes the
+ // on-chain row orange and the Cashu row purple with no colour table to
+ // keep in sync. Guard the mechanism: a literal colour here would drift.
+ const w = mount(BalanceAmount, { props: { sats: null } })
+ expect(w.find('.balance-pixel').exists()).toBe(true)
+ expect(w.html()).not.toMatch(/background:\s*#|rgb\(/)
+ })
+
+ it('renders enough cells to read as a matrix', () => {
+ const w = mount(BalanceAmount, { props: { sats: null } })
+ expect(w.findAll('.balance-pixel').length).toBe(28)
+ })
+})
+
+describe('HomeWalletCard balances', () => {
+ const base = {
+ animate: false,
+ walletConnected: true,
+ walletOnchain: null,
+ walletLightning: null,
+ walletEcash: null,
+ walletFedimint: null,
+ walletArk: null,
+ walletTransactions: [],
+ isDev: false,
+ }
+
+ const mountCard = (props: Record) =>
+ mount(HomeWalletCard, { props: { ...base, ...props }, global: { plugins: [i18n] } })
+
+ it('shows no figures at all before anything has loaded', () => {
+ const w = mountCard({})
+ // Six rows could be showing 0 sats here; none of them may.
+ expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
+ expect(w.text()).not.toMatch(/\b0 sats\b/)
+ })
+
+ it('withholds the total until every rail it sums is known', () => {
+ // A total computed with nulls as 0 would read *lower* than the rails
+ // beneath it — worse than showing nothing, because it looks authoritative.
+ const w = mountCard({ walletOnchain: 5000, walletLightning: null, walletEcash: 0, walletFedimint: 0 })
+ expect(w.text()).not.toContain('5,000 sats\n')
+ expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
+ })
+
+ it('sums the total once every rail has reported', () => {
+ const w = mountCard({ walletOnchain: 9000, walletLightning: 900, walletEcash: 22, walletFedimint: 0 })
+ expect(w.text()).toContain('9,922 sats')
+ expect(w.findAll('.balance-pixels').length).toBe(0)
+ })
+
+ it('shows an empty wallet as zero rather than as loading', () => {
+ const w = mountCard({ walletOnchain: 0, walletLightning: 0, walletEcash: 0, walletFedimint: 0 })
+ expect(w.findAll('.balance-pixels').length).toBe(0)
+ expect(w.text()).toContain('0 sats')
+ })
+
+ it('keeps the Ark row hidden while its balance is unknown', () => {
+ // Ark only appears once barkd reports something; "unknown" must not be
+ // read as "> 0" and conjure a row on the many nodes with no Ark sidecar.
+ const loaded = { walletOnchain: 1, walletLightning: 0, walletEcash: 0, walletFedimint: 0 }
+ expect(mountCard({ ...loaded, walletArk: null }).text()).not.toContain('Ark')
+ expect(mountCard({ ...loaded, walletArk: 0 }).text()).not.toContain('Ark')
+ expect(mountCard({ ...loaded, walletArk: 7 }).text()).toContain('Ark')
+ })
+})
diff --git a/neode-ui/src/views/GoalDetail.vue b/neode-ui/src/views/GoalDetail.vue
index 11289c33..41036292 100644
--- a/neode-ui/src/views/GoalDetail.vue
+++ b/neode-ui/src/views/GoalDetail.vue
@@ -121,8 +121,8 @@