Demo images / Build & push demo images (push) Failing after 2m6s
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.
Acknowledgement now lives in localStorage, capped at 300 keys.
That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.
Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.
Frontend: 1000 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
6.0 KiB
TypeScript
140 lines
6.0 KiB
TypeScript
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('never prints NaN at a person', () => {
|
|
// Arithmetic over a missing field produces NaN, which is not caught by a
|
|
// null check and renders as the literal text "NaN sats" — worse than the
|
|
// zero this component exists to prevent, because at least a zero looks
|
|
// like a number.
|
|
for (const bad of [NaN, Infinity, -Infinity]) {
|
|
const w = mount(BalanceAmount, { props: { sats: bad } })
|
|
expect(w.find('.balance-pixels').exists()).toBe(true)
|
|
expect(w.text()).toBe('')
|
|
}
|
|
})
|
|
|
|
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 a 14x3 matrix scanned column by column', () => {
|
|
// Column-first layout is what makes the lit column travel across as one
|
|
// scan line; per-cell delays would make it crawl diagonally instead.
|
|
const w = mount(BalanceAmount, { props: { sats: null } })
|
|
const cells = w.findAll('.balance-pixel')
|
|
expect(cells.length).toBe(42)
|
|
// The three cells of a column share a delay; the next column steps on.
|
|
const delay = (i: number) => cells[i]?.attributes('style') ?? ''
|
|
expect(delay(0)).toBe(delay(1))
|
|
expect(delay(1)).toBe(delay(2))
|
|
expect(delay(3)).not.toBe(delay(2))
|
|
})
|
|
})
|
|
|
|
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<string, unknown>) =>
|
|
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')
|
|
})
|
|
})
|