Skip to content

Commit

Permalink
Add PlayLog test 13
Browse files Browse the repository at this point in the history
  • Loading branch information
stoyicker committed Jun 27, 2024
1 parent 4d11c52 commit bab26d9
Show file tree
Hide file tree
Showing 5 changed files with 266 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.tidal.sdk.player.playlog

import assertk.Assert
import assertk.assertions.isCloseTo

internal fun Assert<Double>.isAssetPositionEqualTo(targetPosition: Double) =
isCloseTo(targetPosition, 0.5)
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ package com.tidal.sdk.player.playlog

import android.app.Application
import androidx.test.platform.app.InstrumentationRegistry
import assertk.Assert
import assertk.assertThat
import assertk.assertions.isBetween
import assertk.assertions.isCloseTo
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import com.google.gson.Gson
Expand Down Expand Up @@ -795,8 +793,4 @@ internal class SingleMediaProductPlayLogTest(private val mediaProduct: MediaProd
eq(emptyMap()),
)
}

private fun Assert<Double>.isAssetPositionEqualTo(targetPosition: Double) = run {
isCloseTo(targetPosition, 0.5)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
package com.tidal.sdk.player.playlog

import android.app.Application
import androidx.test.platform.app.InstrumentationRegistry
import assertk.assertThat
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.tidal.sdk.auth.CredentialsProvider
import com.tidal.sdk.auth.model.AuthResult
import com.tidal.sdk.auth.model.Credentials
import com.tidal.sdk.auth.util.isLoggedIn
import com.tidal.sdk.common.TidalMessage
import com.tidal.sdk.eventproducer.EventSender
import com.tidal.sdk.eventproducer.model.ConsentCategory
import com.tidal.sdk.player.Player
import com.tidal.sdk.player.common.model.MediaProduct
import com.tidal.sdk.player.common.model.ProductType
import com.tidal.sdk.player.events.EventReporterModuleRoot
import com.tidal.sdk.player.events.di.DefaultEventReporterComponent
import com.tidal.sdk.player.events.playlogtest.PlayLogTestDefaultEventReporterComponentFactory
import com.tidal.sdk.player.events.reflectionComponentFactoryF
import com.tidal.sdk.player.playbackengine.model.Event.MediaProductEnded
import com.tidal.sdk.player.playbackengine.model.Event.Release
import com.tidal.sdk.player.setBodyFromFile
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.mockito.Mockito.atMost
import org.mockito.Mockito.mock
import org.mockito.Mockito.verifyNoMoreInteractions
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.eq
import org.mockito.kotlin.verify

@RunWith(Parameterized::class)
internal class TwoMediaProductsPlayLogTest(
private val mediaProduct1: MediaProduct,
private val mediaProduct2: MediaProduct,
) {

@get:Rule
val server = MockWebServer()

private val eventReporterCoroutineScope =
TestScope(StandardTestDispatcher(TestCoroutineScheduler()))
private val responseDispatcher = PlayLogTestMockWebServerDispatcher(server)
private val eventSender = mock<EventSender>()
private lateinit var player: Player

@Before
fun setUp() {
responseDispatcher[
"https://api.tidal.com/v1/tracks/${mediaProduct1.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(),
] = {
MockResponse().setBodyFromFile(
"api-responses/playbackinfo/tracks/playlogtest/get_1_bts.json",
)
}
responseDispatcher["https://test.audio.tidal.com/1_bts.m4a".toHttpUrl()] = {
MockResponse().setBodyFromFile("raw/playlogtest/1_bts.m4a")
}
responseDispatcher[
"https://api.tidal.com/v1/tracks/${mediaProduct2.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(),
] = {
MockResponse().setBodyFromFile(
"api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json",
)
}
responseDispatcher["https://test.audio.tidal.com/test_1min.m4a".toHttpUrl()] = {
MockResponse().setBodyFromFile("raw/playlogtest/test_1min.m4a")
}
EventReporterModuleRoot.reflectionComponentFactoryF = {
PlayLogTestDefaultEventReporterComponentFactory(eventReporterCoroutineScope)
}
server.dispatcher = responseDispatcher

player = Player(
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
as Application,
object : CredentialsProvider {
private val CREDENTIALS = Credentials(
clientId = "a client id",
requestedScopes = emptySet(),
clientUniqueKey = null,
grantedScopes = emptySet(),
userId = "a non-null user id",
expires = null,
token = "a non-null token",
)

override val bus: Flow<TidalMessage> = emptyFlow()

override suspend fun getCredentials(apiErrorSubStatus: String?) =
AuthResult.Success(CREDENTIALS)

override fun isUserLoggedIn() = CREDENTIALS.isLoggedIn()
},
eventSender = eventSender,
okHttpClient = OkHttpClient.Builder()
.addInterceptor {
val request = it.request()
val mockWebServerUrl = responseDispatcher.urlRecords[request.url]
?: return@addInterceptor it.proceed(request)
it.proceed(
request.newBuilder()
.url(mockWebServerUrl)
.build(),
)
}.build(),
)
}

companion object {
private lateinit var originalEventReporterComponentFactoryF:
() -> DefaultEventReporterComponent.Factory
private const val MEDIA_PRODUCT_1_DURATION_SECONDS = 5.055
private const val MEDIA_PRODUCT_2_DURATION_SECONDS = 60.606667

@BeforeClass
@JvmStatic
fun beforeAll() {
originalEventReporterComponentFactoryF =
EventReporterModuleRoot.reflectionComponentFactoryF
}

@JvmStatic
@Parameterized.Parameters
fun parameters(): List<Array<MediaProduct>> {
val mediaProduct1s = setOf(
MediaProduct(ProductType.TRACK, "1", "TESTA", "456"),
MediaProduct(ProductType.TRACK, "1", null, "789"),
MediaProduct(ProductType.TRACK, "1", "TESTB", null),
MediaProduct(ProductType.TRACK, "1", null, null),
)
val mediaProduct2s = setOf(
MediaProduct(ProductType.TRACK, "2", "TESTA", "456"),
MediaProduct(ProductType.TRACK, "2", null, "789"),
MediaProduct(ProductType.TRACK, "2", "TESTB", null),
MediaProduct(ProductType.TRACK, "2", null, null),
)
return mediaProduct1s.flatMap { mediaProduct1 ->
mediaProduct2s.map { mediaProduct2 ->
arrayOf(mediaProduct1, mediaProduct2)
}
}
}
}

@After
fun afterEach() {
runBlocking {
val job = launch { player.playbackEngine.events.first { it is Release } }
player.release()
job.join()
}
verify(eventSender, atMost(Int.MAX_VALUE))
.sendEvent(
argThat { !contentEquals("playback_session") },
anyOrNull(),
anyOrNull(),
anyOrNull(),
)
verifyNoMoreInteractions(eventSender)
}

@Test
fun playSequentially() = runTest(timeout = 3.minutes) {
player.playbackEngine.load(mediaProduct1)
player.playbackEngine.setNext(mediaProduct2)
player.playbackEngine.play()
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(2.minutes) {
player.playbackEngine.events.filter { it is MediaProductEnded }.first()
}
}

eventReporterCoroutineScope.advanceUntilIdle()
verify(eventSender).sendEvent(
eq("playback_session"),
eq(ConsentCategory.NECESSARY),
argThat {
with(Gson().fromJson(this, JsonObject::class.java)["payload"].asJsonObject) {
// https://github.com/androidx/media/issues/1252
assertThat(get("startAssetPosition").asDouble).isAssetPositionEqualTo(0.0)
// https://github.com/androidx/media/issues/1253
get("endAssetPosition").asDouble
.isAssetPositionEqualTo(MEDIA_PRODUCT_1_DURATION_SECONDS) &&
get("actualProductId").asString.contentEquals(mediaProduct1.productId) &&
get("sourceType")?.asString.contentEquals(mediaProduct1.sourceType) &&
get("sourceId")?.asString.contentEquals(mediaProduct1.sourceId) &&
get("actions").asJsonArray.isEmpty
}
},
eq(emptyMap()),
)
verify(eventSender).sendEvent(
eq("playback_session"),
eq(ConsentCategory.NECESSARY),
argThat {
with(Gson().fromJson(this, JsonObject::class.java)["payload"].asJsonObject) {
// https://github.com/androidx/media/issues/1252
assertThat(get("startAssetPosition").asDouble).isAssetPositionEqualTo(0.0)
// https://github.com/androidx/media/issues/1253
get("endAssetPosition").asDouble
.isAssetPositionEqualTo(MEDIA_PRODUCT_2_DURATION_SECONDS) &&
get("actualProductId").asString.contentEquals(mediaProduct2.productId) &&
get("sourceType")?.asString.contentEquals(mediaProduct2.sourceType) &&
get("sourceId")?.asString.contentEquals(mediaProduct2.sourceId) &&
get("actions").asJsonArray.isEmpty
}
},
eq(emptyMap()),
)
}

private fun Double.isAssetPositionEqualTo(targetPosition: Double) =
(this - targetPosition).absoluteValue < 0.5
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"trackId": 2,
"assetPresentation": "FULL",
"audioMode": "STEREO",
"audioQuality": "LOW",
"streamingSessionId": "356",
"manifestMimeType": "application/vnd.tidal.bts",
"manifestHash": "a manifest hash",
"manifest": "eyJtaW1lVHlwZSI6ImF1ZGlvL21wNCIsImNvZGVjcyI6Im1wNGEuNDAuNSIsInVybHMiOlsiaHR0cHM6Ly90ZXN0LmF1ZGlvLnRpZGFsLmNvbS90ZXN0XzFtaW4ubTRhIl19",
"albumReplayGain": -9.8,
"albumPeakAmplitude": 0.999923,
"trackReplayGain": -9.8,
"trackPeakAmplitude": 0.999923,
"bitDepth": 16,
"sampleRate": 44100
}
Binary file not shown.

0 comments on commit bab26d9

Please sign in to comment.