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
This commit is contained in:
+7
-4
@@ -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,
|
||||
) {
|
||||
|
||||
+7
-6
@@ -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<Map<String, List<RoomZap>>>(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
|
||||
|
||||
-3
@@ -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()
|
||||
}
|
||||
|
||||
+141
@@ -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<Array<String>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user