package link.roadio.riff.ui import android.os.Looper import androidx.test.platform.app.InstrumentationRegistry import link.roadio.riff.indexer.core.media.MediaId import link.roadio.riff.player.library.PlayerCuetreeClient import link.roadio.riff.player.storage.PlayerProcessStore import link.roadio.riff.player.storage.PlayerStateStore import link.roadio.riff.player.storage.UserStateTestRig import link.roadio.riff.storage.root.ApplicationDataStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.nio.file.Files import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger /** Slow catalog work must not monopolize input dispatch or publish into a replacement profile. */ class ManualQueueResponsivenessInstrumentedTest { private val instrumentation = InstrumentationRegistry.getInstrumentation() @Test fun folderPreparationAndReadyPresentationLeaveTheMainLooperResponsive() = withFixture { controller, store, catalog -> onMain { controller.dispatch(PlayerUiAction.Play("folder:fixture")) } assertEquals("Folder traversal ran on the main looper", 0, catalog.mainAccesses.get()) assertTrue(catalog.entered.await(5, TimeUnit.SECONDS)) var heartbeat = false onMain { heartbeat = true } assertTrue("Input dispatch must remain available during preparation", heartbeat) assertEquals(null, store.loadActiveQueue()) catalog.release.countDown() await { controller.state.screen == PlayerScreen.PLAYBACK } assertEquals(listOf(MediaId(1), MediaId(2), MediaId(3)), store.loadActiveQueue()!!.occurrences.map { it.mediaId }) assertEquals("READY presentation repeated traversal on the main looper", 0, catalog.mainAccesses.get()) assertEquals("Ordinary folder Play traversed the source more than once", 1, catalog.traversals.get()) } @Test fun acceptedQueueRetainsItsCatalogAndCannotNavigateAnotherProfile() = withFixture { controller, store, catalog -> onMain { controller.dispatch(PlayerUiAction.Play("folder:fixture")) } assertEquals("Folder traversal ran on the main looper", 0, catalog.mainAccesses.get()) assertTrue(catalog.entered.await(5, TimeUnit.SECONDS)) onMain { field(controller, "activeProfileId").setInt(controller, 2) field( controller, "state", ).set(controller, controller.state.copy(screen = PlayerScreen.MAIN, status = "Replacement profile")) } catalog.release.countDown() await { store.loadActiveQueue() != null } // Drain the command executor, then the main queue which owns presentation completion. val done = CountDownLatch(1) (field(controller, "profileWorkers").get(controller) as ExecutorService).execute { done.countDown() } assertTrue(done.await(5, TimeUnit.SECONDS)) onMain { assertEquals(PlayerScreen.MAIN, controller.state.screen) assertEquals("Replacement profile", controller.state.status) } assertEquals(1, store.loadActiveQueue()!!.sourceProfileId) assertEquals(0, catalog.mainAccesses.get()) } private class SlowCatalog : BrowserCatalog { val entered = CountDownLatch(1) val release = CountDownLatch(1) val mainAccesses = AtomicInteger() val traversals = AtomicInteger() private val blocked = AtomicBoolean() private val rows = (1L..3L).map { BrowserItem("track:$it", "Track $it", BrowserKind.TRACK, playable = true, mediaId = MediaId(it)) } override fun tracks( contextKey: String, playableOnly: Boolean, ignoreLeadingArticles: Boolean, ): List { traversals.incrementAndGet() if (Looper.myLooper() == Looper.getMainLooper()) { mainAccesses.incrementAndGet() } else if (blocked.compareAndSet(false, true)) { entered.countDown() check(release.await(15, TimeUnit.SECONDS)) } return rows } override fun find(id: String): BrowserItem? = null override fun track(mediaId: MediaId) = rows.firstOrNull { it.mediaId == mediaId } override fun trackKey(mediaId: MediaId): BrowserRowKey? = null override fun preferredBinding(rowId: String): Long? = null override fun available(mediaId: MediaId) = true override fun mediaIds(contextKey: String) = rows.mapTo(linkedSetOf()) { requireNotNull(it.mediaId) } override fun entities(categoryId: String) = emptyList() override fun directoryPaths() = emptyMap>>() override fun trackFacts() = emptyList() } private fun withFixture(test: (RiffPlayerUiController, PlayerStateStore, SlowCatalog) -> Unit) { val context = instrumentation.targetContext check(context.packageName.endsWith(".instrumented")) val storage = ApplicationDataStorage(context) check(storage.prepareForStartup()) UiConfig.load(context) val directory = Files.createTempDirectory(context.cacheDir.toPath(), "queue-responsiveness").toFile() val store = UserStateTestRig(directory).store val storeField = PlayerProcessStore::class.java.getDeclaredField("store").apply { isAccessible = true } val rootField = PlayerProcessStore::class.java.getDeclaredField("rootIdentity").apply { isAccessible = true } val originalStore = storeField.get(PlayerProcessStore) val originalRoot = rootField.get(PlayerProcessStore) storeField.set(PlayerProcessStore, store) rootField.set(PlayerProcessStore, storage.activeRoot().absolutePath) val catalog = SlowCatalog() val folder = BrowserItem("folder:fixture", "Fixture", BrowserKind.FOLDER, playable = true, catalog = catalog) val root = BrowserItem("root", "Library", BrowserKind.ROOT, children = listOf(folder), catalog = catalog) var controller: RiffPlayerUiController? = null try { onMain { controller = RiffPlayerUiController(context).also { current -> (field(current, "libraryClient").get(current) as PlayerCuetreeClient).close() field( current, "state", ).set(current, current.state.copy(root = root, browserPath = emptyList(), screen = PlayerScreen.HIERARCHY)) } } test(requireNotNull(controller), store, catalog) } finally { catalog.release.countDown() onMain { controller?.close() } controller?.let { (field(it, "profileWorkers").get(it) as ExecutorService).awaitTermination(5, TimeUnit.SECONDS) } storeField.set(PlayerProcessStore, originalStore) rootField.set(PlayerProcessStore, originalRoot) store.close() directory.deleteRecursively() } } private fun field( controller: RiffPlayerUiController, name: String, ) = controller.javaClass.getDeclaredField(name).apply { isAccessible = true } private fun onMain(action: () -> Unit) = instrumentation.runOnMainSync(action) private fun await(condition: () -> Boolean) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) var ready = false while (!ready && System.nanoTime() < deadline) { onMain { ready = condition() } if (!ready) Thread.sleep(20) } assertTrue("Queue command did not complete", ready) } }