From 524bc90d5ab595382308dda0bb9200b2e132228a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 12:49:18 +0000 Subject: [PATCH] test(nests): cover RoomZapsAggregator; tidy zap-overlay comments Adds RoomZapsStateTest mirroring RoomReactionsStateTest (grouping, eviction, room-wide keying, idempotent snapshots, dedup). Also drops the unused RoomZapsAggregator.isEmpty(), pins the zap chip's content color to white since BitcoinOrange is theme-independent, and corrects a few doc comments that overstated component/timer sharing. https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR --- .../nests/room/stage/SpeakerZapOverlay.kt | 11 +- .../commons/viewmodels/NestViewModel.kt | 13 +- .../commons/viewmodels/RoomZapsState.kt | 3 - .../commons/viewmodels/RoomZapsStateTest.kt | 141 ++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt index 7e5332684..d99191b32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerZapOverlay.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap @@ -72,9 +73,8 @@ internal fun SpeakerZapOverlay( exit = fadeOut() + scaleOut(targetScale = 0.6f), modifier = modifier, ) { - // Stable ordering by event id keeps the chip row from shuffling - // each tick — reactions sort by createdAt but zaps generally - // arrive one-at-a-time, so id order is good enough. + // Newest zap leads the row, matching SpeakerReactionOverlay's + // most-recent-first ordering so the two overlays read the same. val ordered = zaps.sortedByDescending { it.createdAtSec } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { ordered.take(MAX_VISIBLE_ZAPS).forEach { zap -> @@ -121,7 +121,10 @@ private fun ZapChip(zap: RoomZap) { .alpha(animatedAlpha), shape = MaterialTheme.shapes.small, color = BitcoinOrange.copy(alpha = 0.95f), - contentColor = MaterialTheme.colorScheme.onPrimary, + // BitcoinOrange is theme-independent, so pin the content color + // to white rather than colorScheme.onPrimary (which tracks the + // theme's primary, not this fixed orange). + contentColor = Color.White, tonalElevation = 2.dp, shadowElevation = 1.dp, ) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index 52bfb1c38..0cd319ccd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -176,10 +176,11 @@ class NestViewModel( /** * Recent kind-9735 zap receipts for the floating zap-avatar overlay. - * Same shape as [recentReactions] so the UI can reuse the same - * floating-chip component — keyed by target pubkey, room-wide - * zaps under the empty-string key. Sliding [REACTION_WINDOW_SEC] - * window driven by the platform layer's eviction tick. + * Same map shape as [recentReactions] (keyed by target pubkey, + * room-wide zaps under the empty-string key) so the zap overlay + * can mirror the reaction overlay's grouping logic. Sliding + * [REACTION_WINDOW_SEC] window driven by the platform layer's + * eviction tick. */ private val zapsAgg = RoomZapsAggregator() private val _recentZaps = MutableStateFlow>>(emptyMap()) @@ -637,8 +638,8 @@ class NestViewModel( /** * Drop zaps older than the staleness threshold. Platform layer - * drives this on the same 1-s tick that evicts reactions so both - * floating overlays share a single timer. + * drives this on a 1-s tick — the same cadence as [evictReactions] + * — so the zap and reaction overlays age out in lockstep. */ fun evictZaps(olderThanSec: Long) { if (closed) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt index 164d4759b..10c470fbb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsState.kt @@ -104,7 +104,4 @@ class RoomZapsAggregator { } return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } } - - /** Whether the aggregator currently holds any unevicted zaps. */ - fun isEmpty(): Boolean = byEventId.isEmpty() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt new file mode 100644 index 000000000..57b0bd6fb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomZapsStateTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RoomZapsStateTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val charlie = "c".repeat(64) + + private var nextEventId = 1 + + /** + * Builds a kind-9735 zap receipt tagged for a room (`a`) and, + * optionally, a target participant (`p`). No `bolt11` tag is + * attached, so [LnZapEvent.amount] resolves to null — the + * aggregator's grouping / eviction / dedup logic does not depend + * on the amount, so that keeps the fixtures invoice-free. + */ + private fun zap( + from: String, + to: String?, + createdAt: Long, + id: String = "%064x".format(nextEventId++), + ): LnZapEvent { + val tags = + buildList> { + add(arrayOf("a", "30312:host:room")) + if (to != null) add(arrayOf("p", to)) + }.toTypedArray() + return LnZapEvent( + id = id, + pubKey = from, + createdAt = createdAt, + tags = tags, + content = "", + sig = "0".repeat(128), + ) + } + + @Test + fun fromEventGroupsByTargetPubkey() { + val zap = RoomZap.from(zap(alice, bob, 100L)) + assertEquals(alice, zap.sourcePubkey) + assertEquals(bob, zap.targetPubkey) + assertNull(zap.amountSats) + assertEquals(100L, zap.createdAtSec) + } + + @Test + fun fromEventNullTargetWhenNoPTag() { + val zap = RoomZap.from(zap(alice, null, 100L)) + assertNull(zap.targetPubkey) + } + + @Test + fun aggregatorGroupsByParticipant() { + val agg = RoomZapsAggregator() + agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L) + val snap = agg.apply(zap(charlie, bob, 100L), nowSec = 100L, windowSec = 30L) + + // Two zaps on bob. + assertEquals(setOf(bob), snap.keys) + assertEquals(2, snap[bob]!!.size) + } + + @Test + fun aggregatorEvictsOlderThanWindow() { + val agg = RoomZapsAggregator() + // Old zap (T=70) — outside the window when now=110, windowSec=30. + agg.apply(zap(alice, bob, 70L), nowSec = 70L, windowSec = 30L) + // Fresh zap (T=105) — inside the window. + val snap = agg.apply(zap(charlie, bob, 105L), nowSec = 110L, windowSec = 30L) + + // bob has only the fresh one left. + assertEquals(1, snap[bob]!!.size) + assertEquals(105L, snap[bob]!![0].createdAtSec) + } + + @Test + fun aggregatorRoomWideZapsKeyedByEmptyString() { + val agg = RoomZapsAggregator() + val snap = agg.apply(zap(alice, null, 100L), nowSec = 100L, windowSec = 30L) + + // Room-wide zaps (no `p` tag) land under the empty-string key + // so the map's value-type stays uniform; the UI can split them + // on render. + assertTrue(snap.containsKey("")) + assertEquals(1, snap[""]!!.size) + } + + @Test + fun evictAndSnapshotIsIdempotentWhenNothingChanged() { + val agg = RoomZapsAggregator() + agg.apply(zap(alice, bob, 100L), nowSec = 100L, windowSec = 30L) + val a = agg.evictAndSnapshot(olderThanSec = 90L) + val b = agg.evictAndSnapshot(olderThanSec = 90L) + // Same input → same output, by VALUE (data class equality on + // RoomZap so the StateFlow doesn't re-emit on no-op ticks). + assertEquals(a, b) + } + + @Test + fun aggregatorDedupsRepeatedEventIds() { + val agg = RoomZapsAggregator() + // LocalCache.observeNotes re-emits the full matching list on + // every cache mutation; the same receipt must collapse into one + // overlay entry instead of stacking on each replay. + val sharedId = "f".repeat(64) + val first = zap(alice, bob, 100L, id = sharedId) + val replay = zap(alice, bob, 100L, id = sharedId) + agg.apply(first, nowSec = 100L, windowSec = 30L) + agg.apply(replay, nowSec = 100L, windowSec = 30L) + val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) + + assertEquals(1, snap[bob]!!.size) + } +}