mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
test(desktop): pin the cache fixtures against the weak note store
`DesktopCachePipelineTest` consumes events and then queries the cache for the notes they produced. Nothing in between held a strong reference to those notes -- until this branch, `DesktopLocalCache.notesByAuthor` did, incidentally: an unbounded `ConcurrentHashMap<HexKey, MutableSet<Note>>` that pinned every note the Desktop app ever saw and quietly defeated the point of storing them in a `LargeSoftCache`. Removing that index was the right call, but it took the tests' only referent with it, so a GC landing between `consume` and the assertion now empties the cache. That is what turned `compose-ui-test` red: `FollowingFeedFilter only includes notes from followed users` got `expected:<1> but was:<0>`. It reproduces exactly with two `System.gc()` calls before the query, and it is not specific to that test -- all 46 consume sites have the same shape, so CI's heap picked the victim. Route every consume through an `ingest` helper that pins what the cache built for the lifetime of the test instance, the way a screen holds the notes it is showing in the app. Keep the forced GC in the test that failed, so the contract is asserted rather than left to the heap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkULS5SVq4GHDdoCzajKi8
This commit is contained in:
Vendored
+77
-46
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.desktop.feeds.DesktopNotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopThreadFilter
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
@@ -65,6 +66,28 @@ class DesktopCachePipelineTest {
|
||||
/** Wait for async bundling (250ms bundler + margin) */
|
||||
private suspend fun waitForBundler() = delay(500)
|
||||
|
||||
/**
|
||||
* Strong references to everything the cache built during one test.
|
||||
*
|
||||
* `DesktopLocalCache.notes`, `users` and `addressableNotes` are LargeSoftCaches:
|
||||
* their values are held weakly, so a note nothing else points at can be collected
|
||||
* between the `consume` that created it and the assertion that looks for it. In the
|
||||
* app the screen showing a note is that strong referent; a test has none, so
|
||||
* [ingest] pins the fixtures here for the lifetime of the test instance. Without
|
||||
* this, every consume-then-query test below fails non-deterministically whenever a
|
||||
* GC lands in that window -- which is what CI's tighter heap produces.
|
||||
*/
|
||||
private val pinnedFixtures = mutableListOf<Any>()
|
||||
|
||||
/** Consumes [event] into the cache and pins whatever it created. See [pinnedFixtures]. */
|
||||
private fun DesktopLocalCache.ingest(event: Event): Boolean {
|
||||
val consumed = consume(event, relayUrl, wasVerified = true)
|
||||
pinnedFixtures.addAll(users.filterIntoSet { _, _ -> true })
|
||||
pinnedFixtures.addAll(notes.filterIntoSet { _, _ -> true })
|
||||
pinnedFixtures.addAll(addressableNotes.filterIntoSet { _, _ -> true })
|
||||
return consumed
|
||||
}
|
||||
|
||||
private fun textNote(
|
||||
id: String,
|
||||
pubKey: String,
|
||||
@@ -127,7 +150,7 @@ class DesktopCachePipelineTest {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
val consumed = cache.consume(event, relayUrl, wasVerified = true)
|
||||
val consumed = cache.ingest(event)
|
||||
|
||||
assertTrue(consumed, "First consume should return true")
|
||||
val note = cache.getNoteIfExists("note1".padEnd(64, '0'))
|
||||
@@ -140,8 +163,8 @@ class DesktopCachePipelineTest {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
val secondConsume = cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
val secondConsume = cache.ingest(event)
|
||||
|
||||
assertTrue(!secondConsume, "Second consume of same event should return false")
|
||||
}
|
||||
@@ -151,7 +174,7 @@ class DesktopCachePipelineTest {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey))
|
||||
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
|
||||
assertEquals(setOf(followedPubKey), cache.followedUsers.value)
|
||||
}
|
||||
@@ -168,8 +191,8 @@ class DesktopCachePipelineTest {
|
||||
createdAt = 200,
|
||||
)
|
||||
|
||||
cache.consume(old, relayUrl, wasVerified = true)
|
||||
cache.consume(newer, relayUrl, wasVerified = true)
|
||||
cache.ingest(old)
|
||||
cache.ingest(newer)
|
||||
|
||||
assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value)
|
||||
}
|
||||
@@ -180,8 +203,8 @@ class DesktopCachePipelineTest {
|
||||
val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200)
|
||||
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
|
||||
cache.consume(newer, relayUrl, wasVerified = true)
|
||||
cache.consume(old, relayUrl, wasVerified = true)
|
||||
cache.ingest(newer)
|
||||
cache.ingest(old)
|
||||
|
||||
assertEquals(
|
||||
setOf(followedPubKey, unfollowedPubKey),
|
||||
@@ -197,8 +220,8 @@ class DesktopCachePipelineTest {
|
||||
val note = textNote(noteId, userPubKey)
|
||||
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId)
|
||||
|
||||
cache.consume(note, relayUrl, wasVerified = true)
|
||||
cache.consume(react, relayUrl, wasVerified = true)
|
||||
cache.ingest(note)
|
||||
cache.ingest(react)
|
||||
|
||||
val cachedNote = cache.getNoteIfExists(noteId)!!
|
||||
assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event")
|
||||
@@ -223,7 +246,7 @@ class DesktopCachePipelineTest {
|
||||
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
|
||||
|
||||
// Phase A — hydration path: consume with accountPubkey unbound.
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
assertEquals(
|
||||
emptySet(),
|
||||
cache.followedUsers.value,
|
||||
@@ -235,7 +258,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
// Phase C — relay replay of the SAME event. Must NOT be rejected by
|
||||
// the createdAt gate; must populate _followedUsers.
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
assertEquals(
|
||||
setOf(followedPubKey),
|
||||
cache.followedUsers.value,
|
||||
@@ -248,7 +271,7 @@ class DesktopCachePipelineTest {
|
||||
val cache = DesktopLocalCache()
|
||||
val other = contactList("cl2".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey), createdAt = 100)
|
||||
|
||||
cache.consume(other, relayUrl, wasVerified = true)
|
||||
cache.ingest(other)
|
||||
cache.accountPubkey = userPubKey
|
||||
|
||||
// followedUsers is for the active user only; a non-self kind-3
|
||||
@@ -260,7 +283,7 @@ class DesktopCachePipelineTest {
|
||||
// reject; here we skipped stamping when pubkey was null so a
|
||||
// newer replay lands cleanly).
|
||||
val newer = contactList("cl2b".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey, userPubKey), createdAt = 200)
|
||||
cache.consume(newer, relayUrl, wasVerified = true)
|
||||
cache.ingest(newer)
|
||||
// No direct assertion on internal state; the fact that this
|
||||
// returns without throwing + does not affect followedUsers is
|
||||
// the invariant. The next line documents intent.
|
||||
@@ -286,7 +309,7 @@ class DesktopCachePipelineTest {
|
||||
delay(50)
|
||||
|
||||
val event = textNote("note1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
val note = cache.getNoteIfExists(event.id)!!
|
||||
cache.emitNewNotes(setOf(note))
|
||||
|
||||
@@ -307,9 +330,9 @@ class DesktopCachePipelineTest {
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
|
||||
// Add notes from different authors
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100))
|
||||
cache.ingest(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200))
|
||||
cache.ingest(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300))
|
||||
|
||||
val feed = filter.feed()
|
||||
assertEquals(3, feed.size, "Global feed should contain all text notes")
|
||||
@@ -318,12 +341,20 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `FollowingFeedFilter only includes notes from followed users`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)))
|
||||
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100))
|
||||
cache.ingest(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200))
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
|
||||
// Deliberate: force the weak cache's hand before querying it, so the fixtures'
|
||||
// survival is asserted here instead of left to whatever the heap happens to do.
|
||||
// Without [ingest]'s pinning this collects the notes and the feed comes back empty
|
||||
// -- the shape that failed on CI and passed on a roomier dev heap.
|
||||
System.gc()
|
||||
System.gc()
|
||||
|
||||
val feed = filter.feed()
|
||||
|
||||
assertEquals(1, feed.size, "Following feed should only contain notes from followed users")
|
||||
@@ -333,7 +364,7 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `FollowingFeedFilter returns empty when no follows`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey))
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { emptySet() }
|
||||
val feed = filter.feed()
|
||||
@@ -344,8 +375,8 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `ProfileFeedFilter only shows notes from target pubkey`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100))
|
||||
cache.ingest(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200))
|
||||
|
||||
val filter = DesktopProfileFeedFilter(followedPubKey, cache)
|
||||
val feed = filter.feed()
|
||||
@@ -360,8 +391,8 @@ class DesktopCachePipelineTest {
|
||||
val rootId = "root".padEnd(64, '0')
|
||||
val replyId = "reply".padEnd(64, '0')
|
||||
|
||||
cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote(rootId, userPubKey, createdAt = 100))
|
||||
cache.ingest(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId))
|
||||
|
||||
val filter = DesktopThreadFilter(rootId, cache)
|
||||
val feed = filter.feed()
|
||||
@@ -373,11 +404,11 @@ class DesktopCachePipelineTest {
|
||||
fun `NotificationFeedFilter shows events tagging user`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
val noteId = "note1".padEnd(64, '0')
|
||||
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote(noteId, userPubKey, createdAt = 100))
|
||||
|
||||
// Reaction from someone else targeting user's note
|
||||
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200)
|
||||
cache.consume(react, relayUrl, wasVerified = true)
|
||||
cache.ingest(react)
|
||||
|
||||
val filter = DesktopNotificationFeedFilter(userPubKey, cache)
|
||||
val feed = filter.feed()
|
||||
@@ -397,7 +428,7 @@ class DesktopCachePipelineTest {
|
||||
fun `ViewModel starts in Loading then transitions to Loaded after refresh`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey))
|
||||
|
||||
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
|
||||
|
||||
@@ -436,7 +467,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
// Simulate relay event arriving
|
||||
val event = textNote("n1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(event, relayUrl, wasVerified = true)
|
||||
cache.ingest(event)
|
||||
val note = cache.getNoteIfExists(event.id)!!
|
||||
cache.emitNewNotes(setOf(note))
|
||||
|
||||
@@ -452,7 +483,7 @@ class DesktopCachePipelineTest {
|
||||
fun `Following ViewModel only shows followed users notes via eventStream`() =
|
||||
runBlocking {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)))
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
@@ -460,7 +491,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
// Add followed user's note
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100)
|
||||
cache.consume(e1, relayUrl, wasVerified = true)
|
||||
cache.ingest(e1)
|
||||
val note1 = cache.getNoteIfExists(e1.id)!!
|
||||
cache.emitNewNotes(setOf(note1))
|
||||
waitForBundler()
|
||||
@@ -469,7 +500,7 @@ class DesktopCachePipelineTest {
|
||||
|
||||
// Add unfollowed user's note
|
||||
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200)
|
||||
cache.consume(e2, relayUrl, wasVerified = true)
|
||||
cache.ingest(e2)
|
||||
val note2 = cache.getNoteIfExists(e2.id)!!
|
||||
cache.emitNewNotes(setOf(note2))
|
||||
waitForBundler()
|
||||
@@ -485,7 +516,7 @@ class DesktopCachePipelineTest {
|
||||
// No contact list consumed — followedUsers remains empty
|
||||
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
|
||||
cache.consume(e1, relayUrl, wasVerified = true)
|
||||
cache.ingest(e1)
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
val vm = DesktopFeedViewModel(filter, cache)
|
||||
@@ -505,8 +536,8 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `clear resets all cache state`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("n1".padEnd(64, '0'), userPubKey))
|
||||
cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)))
|
||||
|
||||
cache.clear()
|
||||
|
||||
@@ -522,9 +553,9 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `global feed is sorted newest first`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true)
|
||||
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true)
|
||||
cache.ingest(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100))
|
||||
cache.ingest(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200))
|
||||
cache.ingest(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300))
|
||||
|
||||
val filter = DesktopGlobalFeedFilter(cache)
|
||||
val feed = filter.feed()
|
||||
@@ -550,7 +581,7 @@ class DesktopCachePipelineTest {
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
cache.consume(metadata, relayUrl, wasVerified = true)
|
||||
cache.ingest(metadata)
|
||||
|
||||
val user = cache.getUserIfExists(userPubKey)
|
||||
assertTrue(user != null, "User should exist after metadata consumption")
|
||||
@@ -569,12 +600,12 @@ class DesktopCachePipelineTest {
|
||||
|
||||
// Create a text note
|
||||
val textEvent = textNote("t1".padEnd(64, '0'), userPubKey)
|
||||
cache.consume(textEvent, relayUrl, wasVerified = true)
|
||||
cache.ingest(textEvent)
|
||||
val textNote = cache.getNoteIfExists(textEvent.id)!!
|
||||
|
||||
// Create a reaction (not a text note)
|
||||
val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0'))
|
||||
cache.consume(reactEvent, relayUrl, wasVerified = true)
|
||||
cache.ingest(reactEvent)
|
||||
val reactNote = cache.getNoteIfExists(reactEvent.id)!!
|
||||
|
||||
val filtered = filter.applyFilter(setOf(textNote, reactNote))
|
||||
@@ -586,16 +617,16 @@ class DesktopCachePipelineTest {
|
||||
@Test
|
||||
fun `FollowingFeedFilter applyFilter respects follow set`() {
|
||||
val cache = DesktopLocalCache().apply { accountPubkey = userPubKey }
|
||||
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
|
||||
cache.ingest(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)))
|
||||
|
||||
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
|
||||
|
||||
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
|
||||
cache.consume(e1, relayUrl, wasVerified = true)
|
||||
cache.ingest(e1)
|
||||
val note1 = cache.getNoteIfExists(e1.id)!!
|
||||
|
||||
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey)
|
||||
cache.consume(e2, relayUrl, wasVerified = true)
|
||||
cache.ingest(e2)
|
||||
val note2 = cache.getNoteIfExists(e2.id)!!
|
||||
|
||||
val filtered = filter.applyFilter(setOf(note1, note2))
|
||||
@@ -655,7 +686,7 @@ class DesktopCachePipelineTest {
|
||||
sig = dummySig,
|
||||
)
|
||||
|
||||
cache.consume(metadata, relayUrl, wasVerified = true)
|
||||
cache.ingest(metadata)
|
||||
|
||||
val user = cache.getUserIfExists(userPubKey)!!
|
||||
val cached = user.metadataOrNull()
|
||||
|
||||
Reference in New Issue
Block a user