package com.suno.android.media.hooks.preload import android.net.Uri import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.preload.DefaultPreloadManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.constants.SunoMediaType import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.ClipStatus import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.media.hooks.toMediaItem import io.mockk.Runs import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import org.junit.After import org.junit.Before import org.junit.Test import java.time.LocalDateTime import kotlin.time.Duration.Companion.seconds class HooksFeedPreloadManagerTest { private val defaultPreloadManagerBuilder = mockk() private val preloadManager = mockk() private val hooksFeedPreloadStatusControl = mockk() private val mediaSource = mockk() private val loggerFactory = mockk(relaxed = true) private lateinit var subject: HooksFeedPreloadManager @Before fun setUp() { mockkStatic("com.suno.android.media.hooks.LocalHookDataExtensionsKt") mockkStatic("android.net.Uri") every { defaultPreloadManagerBuilder.build() } returns preloadManager every { preloadManager.addListener(any()) } just Runs every { preloadManager.add(any(), any()) } just Runs every { preloadManager.invalidate() } just Runs every { preloadManager.remove(any()) } returns true every { preloadManager.removeListener(any()) } returns Unit every { preloadManager.reset() } just Runs every { preloadManager.getMediaSource(any()) } returns mediaSource every { hooksFeedPreloadStatusControl.getCurrentlyPlayingIndex() } returns 0 every { hooksFeedPreloadStatusControl.updateCurrentlyPlayingIndex(any()) } just Runs every { Uri.parse(any()) } returns mockk() subject = HooksFeedPreloadManager( loggerFactory = loggerFactory, defaultPreloadManagerBuilder = defaultPreloadManagerBuilder, hooksFeedPreloadStatusControl = hooksFeedPreloadStatusControl, ) } @After fun tearDown() { unmockkAll() } @Test fun `given single hook when addHooks called then adds hook to preload manager and media items`() { val (hooks, mediaItems) = setupHooksWithMediaItems(count = 1, startId = "hook1") subject.addHooks(hooks = hooks) verify { preloadManager.add(mediaItems[0], 0) preloadManager.invalidate() } } @Test fun `given multiple hooks when addHooks called then adds all hooks with incremental indices`() { val (hooks, mediaItems) = setupHooksWithMediaItems(count = 3, startId = "hook") subject.addHooks(hooks = hooks) verify { preloadManager.add(mediaItems[0], 0) preloadManager.add(mediaItems[1], 1) preloadManager.add(mediaItems[2], 2) preloadManager.invalidate() } } @Test fun `given playing index 15 and 20 media items when addHooks called then removes old items`() { every { hooksFeedPreloadStatusControl.getCurrentlyPlayingIndex() } returns 15 // First, add some initial items to simulate existing media items val initialHooks = (0..19).map { createTestHookData(hookId = "initial$it") } val initialMediaItems = initialHooks.map { createTestMediaItem(mediaId = it.hookId.value) } initialHooks.forEachIndexed { index, hook -> every { hook.toMediaItem() } returns initialMediaItems[index] } subject.addHooks(hooks = initialHooks) // Now add new hooks which should trigger removal of old items val newHooks = listOf(createTestHookData(hookId = "new1")) val newMediaItems = newHooks.map { createTestMediaItem(mediaId = it.hookId.value) } newHooks.forEachIndexed { index, hook -> every { hook.toMediaItem() } returns newMediaItems[index] } subject.addHooks(hooks = newHooks) // Should remove first 5 items (15 - 10 = 5) verify(atLeast = 5) { preloadManager.remove(any()) } } @Test fun `given currently playing index below threshold when addHooks called then does not remove any items`() { listOf(C.INDEX_UNSET, 5, 10).forEach { playingIndex -> every { hooksFeedPreloadStatusControl.getCurrentlyPlayingIndex() } returns playingIndex val (hooks, mediaItems) = setupHooksWithMediaItems(count = 1, startId = "hook$playingIndex") subject.addHooks(hooks = hooks) verify(exactly = 0) { preloadManager.remove(any()) } verify { preloadManager.add(mediaItems[0], any()) } } } @Test fun `when getMediaSource called then delegates to preload manager and returns media source`() { val mediaItem = createTestMediaItem(mediaId = "test") val result = subject.getMediaSource(mediaItem = mediaItem) verify { preloadManager.getMediaSource(mediaItem) } assert(result == mediaSource) } @Test fun `when getMediaSource called with non-existent item then returns null`() { val mediaItem = createTestMediaItem(mediaId = "nonexistent") every { preloadManager.getMediaSource(mediaItem) } returns null val result = subject.getMediaSource(mediaItem = mediaItem) verify { preloadManager.getMediaSource(mediaItem) } assert(result == null) } @Test fun `when updateCurrentlyPlayingIndex called then updates status control and invalidates preload manager`() { val newIndex = 5 subject.updateCurrentlyPlayingIndex(index = newIndex) verify { hooksFeedPreloadStatusControl.updateCurrentlyPlayingIndex(newIndex) preloadManager.invalidate() } } @Test fun `when clear called then removes listener resets preload manager clears media items and resets next index`() { subject.clear() verify { preloadManager.removeListener(any()) preloadManager.reset() } } private fun createTestHookData( hookId: String = "test-hook-id", title: String = "Test Hook", caption: String = "Test Caption", videoUrl: String = "https://example.com/video.mp4", ): LocalHookData = LocalHookData( hookId = Id(hookId), title = title, caption = caption, videoUrl = Url(videoUrl), thumbnailImageUrl = Url("https://example.com/thumbnail.jpg"), viewCount = 100, likeCount = 10, commentCount = 5, currentUserLiked = false, currentUserFollowsCreator = false, creator = LocalHookData.Creator( userId = Id("creator-user-id"), handle = UserHandle("@testcreator"), avatarUrl = Url("https://example.com/avatar.jpg"), displayName = "Test Creator", ), createdAt = LocalDateTime.now(), clip = createTestClipData(), recommendationItemId = null, humanRating = null, lyricDisplayType = null, lyrics = null, clipTime = 1.seconds..15.seconds, ) private fun createTestClipData(): LocalClipData = LocalClipData( clipId = Id("test-clip-id"), mediaType = SunoMediaType.AUDIO, mediaUrl = Url("https://example.com/audio.mp3"), artistName = "Test Artist", artistUserId = Id("artist-user-id"), handle = UserHandle("@testartist"), artistAvatarUrl = Url("https://example.com/avatar.jpg"), nowPlayingTitle = "Test Song Title", albumImageUrl = Url("https://example.com/album.jpg"), videoCoverUrl = null, previewUrl = null, lyricsString = null, caption = null, captionMentions = null, isPublic = true, modelName = "test-model", majorModelVersion = "v100", reaction = null, prompt = "test prompt", gptPrompt = null, tags = "test,tags", displayTags = "test tags", status = ClipStatus.Complete, downloadDisabledReason = null, commentCount = 5, upvoteCount = 10, duration = null, playCount = 100, isFollowing = false, canRemix = true, isRemix = false, remixTask = null, ) private fun createTestMediaItem( mediaId: String = "test-media-id", uriString: String = "https://example.com/test.mp4", ): MediaItem = MediaItem.Builder() .setMediaId(mediaId) .setUri(Uri.parse(uriString)) .build() private fun setupHooksWithMediaItems( count: Int, startId: String, ): Pair, List> { val hooks = (1..count).map { createTestHookData(hookId = "$startId$it") } val mediaItems = hooks.map { createTestMediaItem(mediaId = it.hookId.value) } hooks.forEachIndexed { index, hook -> every { hook.toMediaItem() } returns mediaItems[index] } return hooks to mediaItems } }