diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 0f1ba2911f..8cb6212cc1 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -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() + + /** 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()