diff --git a/neode-ui/src/components/BalanceAmount.vue b/neode-ui/src/components/BalanceAmount.vue
index caaf3cb2..7a037e1e 100644
--- a/neode-ui/src/components/BalanceAmount.vue
+++ b/neode-ui/src/components/BalanceAmount.vue
@@ -52,16 +52,26 @@ function cellDelay(i: number): string {
* space before the unit cannot be eaten by Vue's whitespace condensing — and
* so a test reading `.text()` sees exactly what a person reads on screen.
*/
+/**
+ * Is there a figure to show at all?
+ *
+ * `null`/`undefined` mean "not known yet" — but so does a NaN or an Infinity,
+ * which is what arithmetic on a missing field quietly produces. Those render
+ * as the literal text "NaN sats", which is worse than the zero this component
+ * exists to prevent: at least a zero looks like a number.
+ */
+const known = computed(() => props.sats != null && Number.isFinite(props.sats))
+
const display = computed(() => {
- if (props.sats == null) return ''
- const figure = props.sats.toLocaleString()
+ if (!known.value) return ''
+ const figure = (props.sats as number).toLocaleString()
return props.suffix ? `${figure} ${props.suffix}` : figure
})
{
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.
diff --git a/neode-ui/src/views/__tests__/incomingPayments.test.ts b/neode-ui/src/views/__tests__/incomingPayments.test.ts
index 7eef747f..98e95c7f 100644
--- a/neode-ui/src/views/__tests__/incomingPayments.test.ts
+++ b/neode-ui/src/views/__tests__/incomingPayments.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import i18n from '@/i18n'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
@@ -49,10 +49,16 @@ const mountCard = (transactions: ReturnType[]) =>
global: { plugins: [i18n] },
})
+// Acknowledgement is stored per-browser, so each case starts from a clean
+// slate — otherwise one test's "seen" set silently satisfies the next.
+beforeEach(() => localStorage.clear())
+
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.
+ it('retires a receipt on acknowledgement, never on age alone', async () => {
+ // On a browser that has acknowledged before, an unacknowledged receipt
+ // stays put however old it is — age is not the signal, being seen is.
+ // (A brand-new browser is the separate first-run case below.)
+ localStorage.setItem('archy-seen-incoming-v1', JSON.stringify(['some-earlier-receipt']))
const w = mountCard([tx({ time_stamp: now() - 7200 })])
expect(w.text()).toContain('Incoming 1')
})
@@ -67,6 +73,7 @@ describe('incoming payments', () => {
})
it('marks it seen once the panel is closed again', async () => {
+ localStorage.setItem('archy-seen-incoming-v1', JSON.stringify([]))
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click') // open
@@ -108,6 +115,46 @@ describe('incoming payments', () => {
expect(w.text()).toContain('Incoming 3')
})
+ it('does not show a receipt again after a refresh', async () => {
+ // The bug this whole model was supposed to prevent, and briefly caused:
+ // "seen" lived in component state, so every page load forgot it and the
+ // entire ecash history came back as new. Remounting is a refresh.
+ const received = tx({ amount_sats: 4200, time_stamp: now() - 30 })
+ const first = mountCard([received])
+ const badge = first.find('button')
+ await badge.trigger('click')
+ await badge.trigger('click')
+ expect(first.text()).not.toContain('Incoming 1')
+
+ const afterRefresh = mountCard([received])
+ expect(afterRefresh.text()).not.toContain('Incoming 1')
+ })
+
+ it('does not greet a brand-new browser with the whole history', () => {
+ // Nothing acknowledged yet and a long history: treating all of it as
+ // unseen would be the same wall of old receipts, just from the other
+ // direction. Only what is genuinely recent counts as news on a first run.
+ const old = [
+ tx({ amount_sats: 100, time_stamp: now() - 86400 }),
+ tx({ amount_sats: 200, time_stamp: now() - 3600 }),
+ tx({ amount_sats: 300, time_stamp: now() - 600 }),
+ ]
+ expect(mountCard(old).text()).not.toContain('Incoming')
+
+ // …but a receipt from moments ago still is.
+ localStorage.clear()
+ expect(mountCard([...old, tx({ amount_sats: 400, time_stamp: now() - 5 })]).text())
+ .toContain('Incoming 1')
+ })
+
+ it('survives unreadable storage without resurrecting the history', () => {
+ // A corrupt value must not read as "nothing has been acknowledged" — that
+ // is precisely the refresh bug wearing a different hat.
+ localStorage.setItem('archy-seen-incoming-v1', '{not json')
+ const w = mountCard([tx({ amount_sats: 100, time_stamp: now() - 86400 })])
+ expect(w.text()).not.toContain('Incoming')
+ })
+
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
diff --git a/neode-ui/src/views/home/HomeWalletCard.vue b/neode-ui/src/views/home/HomeWalletCard.vue
index c5adac59..26c4d5a8 100644
--- a/neode-ui/src/views/home/HomeWalletCard.vue
+++ b/neode-ui/src/views/home/HomeWalletCard.vue
@@ -198,7 +198,7 @@