feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s
Demo images / Build & push demo images (push) Failing after 2m18s
An unloaded balance rendered as `0`. Zero is not a loading state — it is
a number, and it is the one number that frightens people. Someone
opening the dashboard while the RPCs were still in flight was told, in
the wallet's own typeface, that their money was gone.
There is no formatting fix for that. The fix is to stop claiming a
figure we do not have, so `null` now means "not known yet" and `0` means
"none", and the two are kept apart end to end: the refs start at null,
a rail becomes a number only when its call actually succeeds, and a
snapshot key that was never written stays unknown instead of becoming a
zero.
In place of the figure, a small dot-matrix scans in the rail's own
colour. It inherits currentColor, so on-chain shimmers orange, Lightning
yellow, Cashu purple, Fedimint blue and Ark teal with no colour table to
keep in sync — and it is sized to the figure it stands in for, so
nothing jumps when the real number lands. It carries role="status" and
names what it is waiting for; a shimmering box with no text is nothing
at all to a screen reader.
Two consequences worth stating. The total is withheld until every rail
that makes it up is known — summing nulls as zero would show a total
*lower* than the rails beneath it, which is worse than showing nothing
because it looks authoritative. And the Ark row stays hidden while its
balance is unknown, since "unknown" must not be read as "> 0" on the
many nodes with no Ark sidecar.
The LND app UI had the same bug in a different shape: its tiles start as
an em-dash, but renderBalances() runs on every poll including before the
first response, and `num(null && …)` is 0 — so the dashes were painted
over with "0 sats" almost immediately. Same treatment, in plain CSS.
Also fixes a stale assertion in AppHeroSection's suite, which has been
red since 9ccc325a changed "Restarting..." to a real ellipsis; and two
test proofs that used a plausible-looking hex string for `C`. The V3
codec never parses that field so it went unnoticed, but the V4 encoder
hands it to the reference implementation, which checks the point is
actually on secp256k1. Real curve points now.
Frontend: 996 tests green. Backend: 1436 green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc98c1d8dd
commit
fa6fe32ef9
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import i18n from '@/i18n'
|
||||
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
|
||||
|
||||
/**
|
||||
* Instant rails (Lightning, Cashu, Fedimint, Ark) settle immediately, so
|
||||
* there is no confirmation count to retire an incoming receipt from the
|
||||
* badge. It used to leave on a five-minute wall clock, which meant a payment
|
||||
* could arrive and vanish before anyone looked — and for ecash, which leaves
|
||||
* no public ledger entry, this panel was the only place the receipt was ever
|
||||
* shown.
|
||||
*
|
||||
* These cases pin the replacement: a receipt stays until it has been seen.
|
||||
*/
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000)
|
||||
|
||||
function tx(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
tx_hash: '',
|
||||
amount_sats: 1000,
|
||||
direction: 'incoming' as const,
|
||||
num_confirmations: 1,
|
||||
time_stamp: now(),
|
||||
total_fees: 0,
|
||||
dest_addresses: [],
|
||||
label: '',
|
||||
block_height: 0,
|
||||
kind: 'cashu' as const,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const base = {
|
||||
animate: false,
|
||||
walletConnected: true,
|
||||
walletOnchain: 0,
|
||||
walletLightning: 0,
|
||||
walletEcash: 0,
|
||||
walletFedimint: 0,
|
||||
walletArk: 0,
|
||||
isDev: false,
|
||||
}
|
||||
|
||||
const mountCard = (transactions: ReturnType<typeof tx>[]) =>
|
||||
mount(HomeWalletCard, {
|
||||
props: { ...base, walletTransactions: transactions },
|
||||
global: { plugins: [i18n] },
|
||||
})
|
||||
|
||||
describe('incoming payments', () => {
|
||||
it('keeps an hours-old instant receipt in the badge until it is seen', async () => {
|
||||
// The regression: two hours past the old five-minute window. Nobody has
|
||||
// looked at it yet, so it is still news.
|
||||
const w = mountCard([tx({ time_stamp: now() - 7200 })])
|
||||
expect(w.text()).toContain('Incoming 1')
|
||||
})
|
||||
|
||||
it('does not clear the receipt merely because the panel was opened', async () => {
|
||||
// "Selecting incoming clears a pending token" — opening must show it,
|
||||
// not consume it. Someone reading the row must be able to keep reading.
|
||||
const w = mountCard([tx()])
|
||||
await w.find('button').trigger('click')
|
||||
expect(w.text()).toContain('Incoming Transactions')
|
||||
expect(w.text()).toContain('+1,000 sats')
|
||||
})
|
||||
|
||||
it('marks it seen once the panel is closed again', async () => {
|
||||
const w = mountCard([tx()])
|
||||
const badge = w.find('button')
|
||||
await badge.trigger('click') // open
|
||||
await badge.trigger('click') // close — acknowledges
|
||||
expect(w.text()).not.toContain('Incoming 1')
|
||||
})
|
||||
|
||||
it('still surfaces a payment that arrives after an earlier one was seen', async () => {
|
||||
const first = tx({ amount_sats: 1000, time_stamp: now() - 60 })
|
||||
const w = mountCard([first])
|
||||
const badge = w.find('button')
|
||||
await badge.trigger('click')
|
||||
await badge.trigger('click')
|
||||
expect(w.text()).not.toContain('Incoming 1')
|
||||
|
||||
// A different payment must not inherit the first one's acknowledgement.
|
||||
await w.setProps({ walletTransactions: [first, tx({ amount_sats: 2500, time_stamp: now() })] })
|
||||
expect(w.text()).toContain('Incoming 1')
|
||||
})
|
||||
|
||||
it('leaves on-chain transactions on their confirmation count', () => {
|
||||
// On-chain has a real signal and is deliberately untouched: it drops out
|
||||
// at three confirmations regardless of whether anyone looked.
|
||||
const unconfirmed = mountCard([tx({ kind: 'onchain', num_confirmations: 0, tx_hash: 'abc' })])
|
||||
expect(unconfirmed.text()).toContain('Incoming 1')
|
||||
|
||||
const settled = mountCard([tx({ kind: 'onchain', num_confirmations: 6, tx_hash: 'abc' })])
|
||||
expect(settled.text()).not.toContain('Incoming 1')
|
||||
})
|
||||
|
||||
it('lists several instant receipts separately even without txids', () => {
|
||||
// Instant rails carry no txid, so keying the list on tx_hash gave every
|
||||
// row the same empty key and Vue reused one node for all of them.
|
||||
const w = mountCard([
|
||||
tx({ amount_sats: 1000, time_stamp: now() - 10 }),
|
||||
tx({ amount_sats: 2000, time_stamp: now() - 20, kind: 'lightning' }),
|
||||
tx({ amount_sats: 3000, time_stamp: now() - 30, kind: 'fedimint' }),
|
||||
])
|
||||
expect(w.text()).toContain('Incoming 3')
|
||||
})
|
||||
|
||||
it('does not silently turn into a navigation button while the panel is open', async () => {
|
||||
// The badge is two controls in one: with receipts it toggles the panel,
|
||||
// without them it navigates to the full transactions view. If the list
|
||||
// empties while the panel is open, the click under the user's cursor used
|
||||
// to change meaning and take them to another screen.
|
||||
const w = mountCard([tx()])
|
||||
const badge = w.find('button')
|
||||
await badge.trigger('click')
|
||||
expect(w.text()).toContain('Incoming Transactions')
|
||||
|
||||
await w.setProps({ walletTransactions: [] })
|
||||
await badge.trigger('click')
|
||||
expect(w.emitted('showTransactions')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user