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.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
|
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
|
||||||
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
|
import com.vitorpamplona.amethyst.commons.viewmodels.RoomZap
|
||||||
@@ -72,9 +73,8 @@ internal fun SpeakerZapOverlay(
|
|||||||
exit = fadeOut() + scaleOut(targetScale = 0.6f),
|
exit = fadeOut() + scaleOut(targetScale = 0.6f),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
// Stable ordering by event id keeps the chip row from shuffling
|
// Newest zap leads the row, matching SpeakerReactionOverlay's
|
||||||
// each tick — reactions sort by createdAt but zaps generally
|
// most-recent-first ordering so the two overlays read the same.
|
||||||
// arrive one-at-a-time, so id order is good enough.
|
|
||||||
val ordered = zaps.sortedByDescending { it.createdAtSec }
|
val ordered = zaps.sortedByDescending { it.createdAtSec }
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
ordered.take(MAX_VISIBLE_ZAPS).forEach { zap ->
|
ordered.take(MAX_VISIBLE_ZAPS).forEach { zap ->
|
||||||
@@ -121,7 +121,10 @@ private fun ZapChip(zap: RoomZap) {
|
|||||||
.alpha(animatedAlpha),
|
.alpha(animatedAlpha),
|
||||||
shape = MaterialTheme.shapes.small,
|
shape = MaterialTheme.shapes.small,
|
||||||
color = BitcoinOrange.copy(alpha = 0.95f),
|
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,
|
tonalElevation = 2.dp,
|
||||||
shadowElevation = 1.dp,
|
shadowElevation = 1.dp,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+7
-6
@@ -176,10 +176,11 @@ class NestViewModel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Recent kind-9735 zap receipts for the floating zap-avatar overlay.
|
* Recent kind-9735 zap receipts for the floating zap-avatar overlay.
|
||||||
* Same shape as [recentReactions] so the UI can reuse the same
|
* Same map shape as [recentReactions] (keyed by target pubkey,
|
||||||
* floating-chip component — keyed by target pubkey, room-wide
|
* room-wide zaps under the empty-string key) so the zap overlay
|
||||||
* zaps under the empty-string key. Sliding [REACTION_WINDOW_SEC]
|
* can mirror the reaction overlay's grouping logic. Sliding
|
||||||
* window driven by the platform layer's eviction tick.
|
* [REACTION_WINDOW_SEC] window driven by the platform layer's
|
||||||
|
* eviction tick.
|
||||||
*/
|
*/
|
||||||
private val zapsAgg = RoomZapsAggregator()
|
private val zapsAgg = RoomZapsAggregator()
|
||||||
private val _recentZaps = MutableStateFlow<Map<String, List<RoomZap>>>(emptyMap())
|
private val _recentZaps = MutableStateFlow<Map<String, List<RoomZap>>>(emptyMap())
|
||||||
@@ -637,8 +638,8 @@ class NestViewModel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop zaps older than the staleness threshold. Platform layer
|
* Drop zaps older than the staleness threshold. Platform layer
|
||||||
* drives this on the same 1-s tick that evicts reactions so both
|
* drives this on a 1-s tick — the same cadence as [evictReactions]
|
||||||
* floating overlays share a single timer.
|
* — so the zap and reaction overlays age out in lockstep.
|
||||||
*/
|
*/
|
||||||
fun evictZaps(olderThanSec: Long) {
|
fun evictZaps(olderThanSec: Long) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
|||||||
-3
@@ -104,7 +104,4 @@ class RoomZapsAggregator {
|
|||||||
}
|
}
|
||||||
return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey }
|
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