package com.doublesymmetry.trackplayer.models import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableMapKeySetIterator import com.facebook.react.bridge.ReadableType import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic import org.junit.After import org.junit.Before import org.junit.Test import org.junit.Assert.* import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class TrackPlayerMediaItemTest { @Before fun setUp() { // Arguments.createMap() and createArray() call into native code; replace with JavaOnlyMap. mockkStatic(Arguments::class) every { Arguments.createMap() } answers { JavaOnlyMap() } // toBundle / fromBundle traverse the map with native helpers; for tests, hand-roll // a small subset that covers the JSON-shaped values our extras use. every { Arguments.toBundle(any()) } answers { val src = firstArg() ?: return@answers null val bundle = android.os.Bundle() val it = src.keySetIterator() while (it.hasNextKey()) { val k = it.nextKey() when (src.getType(k)) { ReadableType.Null -> bundle.putString(k, null) ReadableType.Boolean -> bundle.putBoolean(k, src.getBoolean(k)) ReadableType.Number -> bundle.putDouble(k, src.getDouble(k)) ReadableType.String -> bundle.putString(k, src.getString(k)) else -> {} // skip nested maps/arrays for these tests } } bundle } every { Arguments.fromBundle(any()) } answers { // Arguments.fromBundle(Bundle): WritableMap is non-null in current RN. val src = firstArg() val map = JavaOnlyMap() for (k in src.keySet()) { when (val v = src.get(k)) { null -> map.putNull(k) is Boolean -> map.putBoolean(k, v) is Double -> map.putDouble(k, v) is Int -> map.putInt(k, v) is String -> map.putString(k, v) else -> {} // skip nested types for these tests } } map } } @After fun tearDown() { unmockkStatic(Arguments::class) } // ---- fromReadableMap ---- @Test fun `fromReadableMap parses basic fields`() { val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "String" } every { getString("url") } returns "https://example.com/track.mp3" every { hasKey("mediaId") } returns true every { getString("mediaId") } returns "track-123" every { hasKey("title") } returns true every { getString("title") } returns "My Track" every { hasKey("artist") } returns true every { getString("artist") } returns "My Artist" every { hasKey("albumTitle") } returns true every { getString("albumTitle") } returns "My Album" every { hasKey("artworkUrl") } returns true every { getString("artworkUrl") } returns "https://example.com/art.jpg" every { hasKey("duration") } returns true every { getDouble("duration") } returns 180.5 every { hasKey("isLive") } returns true every { getBoolean("isLive") } returns false every { hasKey("mimeType") } returns false } val item = TrackPlayerMediaItem.fromReadableMap(map) assertEquals("track-123", item.mediaId) assertEquals("https://example.com/track.mp3", item.url) assertEquals("My Track", item.title) assertEquals("My Artist", item.artist) assertEquals("My Album", item.albumTitle) assertEquals("https://example.com/art.jpg", item.artworkUrl) assertEquals(180.5, item.duration!!, 0.001) assertEquals(false, item.isLive) assertNull(item.mimeType) assertNull(item.headers) } @Test fun `fromReadableMap with missing optional fields uses null`() { val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "String" } every { getString("url") } returns "https://example.com/track.mp3" every { hasKey("mediaId") } returns false every { hasKey("title") } returns false every { hasKey("artist") } returns false every { hasKey("albumTitle") } returns false every { hasKey("artworkUrl") } returns false every { hasKey("duration") } returns false every { hasKey("isLive") } returns false every { hasKey("mimeType") } returns false } val item = TrackPlayerMediaItem.fromReadableMap(map) assertNull(item.mediaId) assertEquals("https://example.com/track.mp3", item.url) assertNull(item.title) assertNull(item.artist) assertNull(item.albumTitle) assertNull(item.artworkUrl) assertNull(item.duration) assertNull(item.isLive) assertNull(item.headers) } @Test fun `fromReadableMap with url as object parses uri and headers`() { val headersIterator = mockk { every { hasNextKey() } returnsMany listOf(true, true, false) every { nextKey() } returnsMany listOf("Authorization", "X-Custom") } val headersMap = mockk(relaxed = true) { every { keySetIterator() } returns headersIterator every { getString("Authorization") } returns "Bearer token123" every { getString("X-Custom") } returns "custom-value" } val urlMap = mockk(relaxed = true) { every { getString("uri") } returns "https://example.com/secure.mp3" every { getString("url") } returns null every { getMap("headers") } returns headersMap } val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "Map" } every { getMap("url") } returns urlMap every { hasKey("mediaId") } returns false every { hasKey("title") } returns false every { hasKey("artist") } returns false every { hasKey("albumTitle") } returns false every { hasKey("artworkUrl") } returns false every { hasKey("duration") } returns false every { hasKey("isLive") } returns false every { hasKey("mimeType") } returns false } val item = TrackPlayerMediaItem.fromReadableMap(map) assertEquals("https://example.com/secure.mp3", item.url) assertNotNull(item.headers) assertEquals("Bearer token123", item.headers!!["Authorization"]) assertEquals("custom-value", item.headers!!["X-Custom"]) } @Test fun `fromReadableMap with url object falls back to url key when uri absent`() { val urlMap = mockk(relaxed = true) { every { getString("uri") } returns null every { getString("url") } returns "https://example.com/fallback.mp3" every { getMap("headers") } returns null } val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "Map" } every { getMap("url") } returns urlMap every { hasKey("mediaId") } returns false every { hasKey("title") } returns false every { hasKey("artist") } returns false every { hasKey("albumTitle") } returns false every { hasKey("artworkUrl") } returns false every { hasKey("duration") } returns false every { hasKey("isLive") } returns false every { hasKey("mimeType") } returns false } val item = TrackPlayerMediaItem.fromReadableMap(map) assertEquals("https://example.com/fallback.mp3", item.url) } // ---- asMediaItem ---- @Test fun `asMediaItem produces correct Media3 MediaItem`() { val item = TrackPlayerMediaItem( mediaId = "track-abc", url = "https://example.com/track.mp3", title = "Test Track", artist = "Test Artist", albumTitle = "Test Album", artworkUrl = "https://example.com/art.jpg", ) val mediaItem = item.asMediaItem() assertEquals("track-abc", mediaItem.mediaId) assertNotNull(mediaItem.localConfiguration) assertEquals("https://example.com/track.mp3", mediaItem.localConfiguration!!.uri.toString()) assertEquals("Test Track", mediaItem.mediaMetadata.title.toString()) assertEquals("Test Artist", mediaItem.mediaMetadata.artist.toString()) assertEquals("Test Album", mediaItem.mediaMetadata.albumTitle.toString()) assertEquals("https://example.com/art.jpg", mediaItem.mediaMetadata.artworkUri.toString()) } @Test fun `asMediaItem uses url as mediaId when mediaId is null`() { val item = TrackPlayerMediaItem( mediaId = null, url = "https://example.com/track.mp3", ) val mediaItem = item.asMediaItem() assertEquals("https://example.com/track.mp3", mediaItem.mediaId) } @Test fun `asMediaItem stores duration and isLive in extras`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", duration = 240.0, isLive = true, ) val mediaItem = item.asMediaItem() val extras = mediaItem.mediaMetadata.extras assertNotNull(extras) assertEquals(240.0, extras!!.getDouble("duration"), 0.001) assertTrue(extras.getBoolean("isLive")) assertNotNull(mediaItem.liveConfiguration) } @Test fun `asMediaItem with headers stores headers in request extras`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", headers = mapOf("Authorization" to "Bearer xyz", "Accept" to "audio/*"), ) val mediaItem = item.asMediaItem() val requestExtras = mediaItem.requestMetadata.extras assertNotNull(requestExtras) val headerBundle = requestExtras!!.getBundle("headers") assertNotNull(headerBundle) assertEquals("Bearer xyz", headerBundle!!.getString("Authorization")) assertEquals("audio/*", headerBundle.getString("Accept")) } @Test fun `asMediaItem sets mediaUri in requestMetadata for http urls`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", ) val mediaItem = item.asMediaItem() // HTTP URIs should be set as mediaUri for Cast support assertNotNull(mediaItem.requestMetadata.mediaUri) assertEquals("https://example.com/track.mp3", mediaItem.requestMetadata.mediaUri.toString()) } @Test fun `asMediaItem does not set mediaUri for file urls`() { val item = TrackPlayerMediaItem( url = "file:///storage/music/local.mp3", ) val mediaItem = item.asMediaItem() // Local file URIs should not be set as mediaUri (inaccessible from Chromecast) assertNull(mediaItem.requestMetadata.mediaUri) } @Test fun `asMediaItem sets mimeType when provided`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", mimeType = "audio/mpeg", ) val mediaItem = item.asMediaItem() assertEquals("audio/mpeg", mediaItem.localConfiguration?.mimeType) } @Test fun `asMediaItem does not set mediaUri for content urls`() { val item = TrackPlayerMediaItem( url = "content://com.android.providers.media.documents/document/audio:42", ) // content:// URIs aren't reachable from a Chromecast either — same // reasoning as the file:// guard. assertNull(item.asMediaItem().requestMetadata.mediaUri) } @Test fun `asMediaItem does not set mediaUri for android-resource urls`() { val item = TrackPlayerMediaItem( url = "android.resource://com.example/raw/song", ) assertNull(item.asMediaItem().requestMetadata.mediaUri) } @Test fun `asMediaItem does not set mediaUri for asset urls`() { // asset:// is the canonical form JS emits for bare RN asset names. // Without a context to resolve, asMediaItem() keeps it untouched — // either way it isn't reachable from a Chromecast. val item = TrackPlayerMediaItem(url = "asset://song") assertNull(item.asMediaItem().requestMetadata.mediaUri) } @Test fun `asMediaItem passes file urls through unchanged to setUri`() { val item = TrackPlayerMediaItem(url = "file:///storage/music/song.mp3") val mediaItem = item.asMediaItem() assertEquals( "file:///storage/music/song.mp3", mediaItem.localConfiguration?.uri.toString(), ) } @Test fun `asMediaItem with context leaves http urls alone`() { val item = TrackPlayerMediaItem(url = "https://example.com/track.mp3") val mediaItem = item.asMediaItem( androidx.test.core.app.ApplicationProvider.getApplicationContext() ) assertEquals( "https://example.com/track.mp3", mediaItem.localConfiguration?.uri.toString(), ) } @Test fun `asMediaItem with context leaves file urls alone`() { val item = TrackPlayerMediaItem(url = "file:///storage/music/song.mp3") val mediaItem = item.asMediaItem( androidx.test.core.app.ApplicationProvider.getApplicationContext() ) assertEquals( "file:///storage/music/song.mp3", mediaItem.localConfiguration?.uri.toString(), ) } @Test fun `resolveAssetUrl returns input unchanged for non-asset schemes`() { val ctx = androidx.test.core.app.ApplicationProvider .getApplicationContext() assertEquals( "https://example.com/song.mp3", TrackPlayerMediaItem.resolveAssetUrl(ctx, "https://example.com/song.mp3"), ) assertEquals( "file:///storage/music/song.mp3", TrackPlayerMediaItem.resolveAssetUrl(ctx, "file:///storage/music/song.mp3"), ) } @Test fun `resolveAssetUrl returns input unchanged when asset name is unknown`() { // No resource named "definitely_does_not_exist" — fall through to // the original asset:// string so ExoPlayer can surface the error. val ctx = androidx.test.core.app.ApplicationProvider .getApplicationContext() assertEquals( "asset://definitely_does_not_exist", TrackPlayerMediaItem.resolveAssetUrl(ctx, "asset://definitely_does_not_exist"), ) } @Test fun `resolveAssetUrl returns input unchanged for empty asset name`() { val ctx = androidx.test.core.app.ApplicationProvider .getApplicationContext() assertEquals("asset://", TrackPlayerMediaItem.resolveAssetUrl(ctx, "asset://")) } // ---- toWritableMap ---- @Test fun `toWritableMap includes all fields`() { val item = TrackPlayerMediaItem( mediaId = "t1", url = "https://example.com/track.mp3", title = "Track", artist = "Artist", albumTitle = "Album", artworkUrl = "https://example.com/art.jpg", duration = 120.0, isLive = false, ) val map = item.toWritableMap() // WritableMap from Arguments.createMap() — verify no exception thrown and key existence // (JavaOnlyMap in Robolectric supports getString/getDouble/getBoolean) assertEquals("t1", map.getString("mediaId")) assertEquals("https://example.com/track.mp3", map.getString("url")) assertEquals("Track", map.getString("title")) assertEquals("Artist", map.getString("artist")) assertEquals("Album", map.getString("albumTitle")) assertEquals("https://example.com/art.jpg", map.getString("artworkUrl")) assertEquals(120.0, map.getDouble("duration"), 0.001) assertFalse(map.getBoolean("isLive")) } @Test fun `toWritableMap uses url as mediaId when mediaId is null`() { val item = TrackPlayerMediaItem( mediaId = null, url = "https://example.com/track.mp3", ) val map = item.toWritableMap() assertEquals("https://example.com/track.mp3", map.getString("mediaId")) } @Test fun `toWritableMap omits null optional fields`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", ) val map = item.toWritableMap() assertFalse(map.hasKey("title")) assertFalse(map.hasKey("artist")) assertFalse(map.hasKey("albumTitle")) assertFalse(map.hasKey("artworkUrl")) assertFalse(map.hasKey("duration")) assertFalse(map.hasKey("isLive")) } // ---- fromMediaItem round-trip ---- @Test fun `fromMediaItem round-trips correctly`() { val original = TrackPlayerMediaItem( mediaId = "round-trip-id", url = "https://example.com/track.mp3", title = "Round Trip", artist = "Artist", albumTitle = "Album", artworkUrl = "https://example.com/art.jpg", duration = 90.0, ) val mediaItem = original.asMediaItem() val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem) assertEquals("round-trip-id", restored.mediaId) assertEquals("https://example.com/track.mp3", restored.url) assertEquals("Round Trip", restored.title) assertEquals("Artist", restored.artist) assertEquals("Album", restored.albumTitle) assertEquals("https://example.com/art.jpg", restored.artworkUrl) assertEquals(90.0, restored.duration!!, 0.001) assertNull(restored.isLive) // not set so extras won't contain "isLive" } @Test fun `fromMediaItem preserves isLive true`() { val original = TrackPlayerMediaItem( url = "https://example.com/live.mp3", isLive = true, ) val mediaItem = original.asMediaItem() val restored = TrackPlayerMediaItem.fromMediaItem(mediaItem) assertEquals(true, restored.isLive) } @Test fun `fromMediaItem with no localConfiguration falls back to mediaId as url`() { // Build a Media3 MediaItem without a URI (e.g., cast-only item) val mediaItem = MediaItem.Builder() .setMediaId("cast-item-id") .setMediaMetadata(MediaMetadata.Builder().setTitle("Cast Track").build()) .build() val item = TrackPlayerMediaItem.fromMediaItem(mediaItem) assertEquals("cast-item-id", item.mediaId) // Falls back to mediaId when no localConfiguration assertEquals("cast-item-id", item.url) assertEquals("Cast Track", item.title) } // ---- extras (app-defined payload) ---- @Test fun `fromReadableMap parses extras when present`() { val extrasMap = mockk(relaxed = true) { every { keySetIterator() } returns mockk { every { hasNextKey() } returnsMany listOf(true, true, true, false) every { nextKey() } returnsMany listOf("source", "isrc", "weight") } every { getType("source") } returns ReadableType.String every { getString("source") } returns "recommendations" every { getType("isrc") } returns ReadableType.String every { getString("isrc") } returns "USRC17607839" every { getType("weight") } returns ReadableType.Number every { getDouble("weight") } returns 0.85 } val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "String" } every { getString("url") } returns "https://example.com/track.mp3" every { hasKey("mediaId") } returns false every { hasKey("title") } returns false every { hasKey("artist") } returns false every { hasKey("albumTitle") } returns false every { hasKey("artworkUrl") } returns false every { hasKey("duration") } returns false every { hasKey("isLive") } returns false every { hasKey("mimeType") } returns false every { hasKey("extras") } returns true every { getMap("extras") } returns extrasMap } val item = TrackPlayerMediaItem.fromReadableMap(map) assertNotNull(item.extras) assertEquals("recommendations", item.extras!!.getString("source")) assertEquals("USRC17607839", item.extras!!.getString("isrc")) assertEquals(0.85, item.extras!!.getDouble("weight"), 0.001) } @Test fun `fromReadableMap leaves extras null when absent`() { val map = mockk(relaxed = true) { every { hasKey("url") } returns true every { getType("url") } returns mockk { every { name } returns "String" } every { getString("url") } returns "https://example.com/track.mp3" every { hasKey("mediaId") } returns false every { hasKey("title") } returns false every { hasKey("artist") } returns false every { hasKey("albumTitle") } returns false every { hasKey("artworkUrl") } returns false every { hasKey("duration") } returns false every { hasKey("isLive") } returns false every { hasKey("mimeType") } returns false every { hasKey("extras") } returns false } val item = TrackPlayerMediaItem.fromReadableMap(map) assertNull(item.extras) } @Test fun `asMediaItem stores extras under a namespaced key without polluting known fields`() { val payload = android.os.Bundle().apply { putString("source", "recommendations") putString("isrc", "USRC17607839") } val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", duration = 120.0, isLive = false, extras = payload, ) val mediaItem = item.asMediaItem() val metadataExtras = mediaItem.mediaMetadata.extras assertNotNull(metadataExtras) // Known fields still live at the top level of the metadata extras bundle… assertEquals(120.0, metadataExtras!!.getDouble("duration"), 0.001) assertFalse(metadataExtras.getBoolean("isLive")) // …and the app payload is nested under our namespaced key. val nested = metadataExtras.getBundle("rntp.extras") assertNotNull(nested) assertEquals("recommendations", nested!!.getString("source")) assertEquals("USRC17607839", nested.getString("isrc")) } @Test fun `asMediaItem omits extras bundle when no extras provided`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", ) val mediaItem = item.asMediaItem() val metadataExtras = mediaItem.mediaMetadata.extras // The metadata extras bundle is always created (for duration/isLive), // but our namespaced key should be absent. assertNotNull(metadataExtras) assertFalse(metadataExtras!!.containsKey("rntp.extras")) } @Test fun `toWritableMap includes extras when present`() { val payload = android.os.Bundle().apply { putString("source", "recommendations") putDouble("weight", 0.85) } val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", extras = payload, ) val map = item.toWritableMap() assertTrue(map.hasKey("extras")) val extras = map.getMap("extras") assertNotNull(extras) assertEquals("recommendations", extras!!.getString("source")) assertEquals(0.85, extras.getDouble("weight"), 0.001) } @Test fun `toWritableMap omits extras when absent`() { val item = TrackPlayerMediaItem( url = "https://example.com/track.mp3", ) val map = item.toWritableMap() assertFalse(map.hasKey("extras")) } @Test fun `extras survive full round-trip through Media3 MediaItem`() { val payload = android.os.Bundle().apply { putString("source", "recommendations") putString("isrc", "USRC17607839") putDouble("weight", 0.85) putBoolean("explicit", true) } val original = TrackPlayerMediaItem( mediaId = "t-1", url = "https://example.com/track.mp3", title = "Song", duration = 60.0, extras = payload, ) val restored = TrackPlayerMediaItem.fromMediaItem(original.asMediaItem()) assertEquals("t-1", restored.mediaId) assertEquals("Song", restored.title) assertEquals(60.0, restored.duration!!, 0.001) assertNotNull(restored.extras) assertEquals("recommendations", restored.extras!!.getString("source")) assertEquals("USRC17607839", restored.extras!!.getString("isrc")) assertEquals(0.85, restored.extras!!.getDouble("weight"), 0.001) assertTrue(restored.extras!!.getBoolean("explicit")) } @Test fun `fromMediaItem leaves extras null when source has none`() { val original = TrackPlayerMediaItem(url = "https://example.com/track.mp3") val restored = TrackPlayerMediaItem.fromMediaItem(original.asMediaItem()) assertNull(restored.extras) } }