Skip to content

Commit d576fc1

Browse files
committed
MF-171: Add PlayLog test 13
1 parent 20846c0 commit d576fc1

File tree

5 files changed

+263
-6
lines changed

5 files changed

+263
-6
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.tidal.sdk.player.playlog
2+
3+
import assertk.Assert
4+
import assertk.assertions.isCloseTo
5+
6+
internal fun Assert<Double>.isAssetPositionEqualTo(targetPosition: Double) =
7+
isCloseTo(targetPosition, 0.5)

player/src/androidTest/kotlin/com/tidal/sdk/player/playlog/SingleMediaProductPlayLogTest.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ package com.tidal.sdk.player.playlog
22

33
import android.app.Application
44
import androidx.test.platform.app.InstrumentationRegistry
5-
import assertk.Assert
65
import assertk.assertThat
76
import assertk.assertions.isBetween
8-
import assertk.assertions.isCloseTo
97
import assertk.assertions.isEmpty
108
import assertk.assertions.isEqualTo
119
import com.google.gson.Gson
@@ -775,10 +773,6 @@ internal class SingleMediaProductPlayLogTest {
775773
eq(emptyMap()),
776774
)
777775
}
778-
779-
private fun Assert<Double>.isAssetPositionEqualTo(targetPosition: Double) = run {
780-
isCloseTo(targetPosition, 0.5)
781-
}
782776
}
783777

784778
private const val MEDIA_PRODUCT_DURATION_SECONDS = 5.055
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package com.tidal.sdk.player.playlog
2+
3+
import android.app.Application
4+
import androidx.test.platform.app.InstrumentationRegistry
5+
import com.google.gson.Gson
6+
import com.google.gson.JsonObject
7+
import com.tidal.sdk.auth.CredentialsProvider
8+
import com.tidal.sdk.auth.model.AuthResult
9+
import com.tidal.sdk.auth.model.Credentials
10+
import com.tidal.sdk.auth.util.isLoggedIn
11+
import com.tidal.sdk.common.TidalMessage
12+
import com.tidal.sdk.eventproducer.EventSender
13+
import com.tidal.sdk.eventproducer.model.ConsentCategory
14+
import com.tidal.sdk.player.Player
15+
import com.tidal.sdk.player.common.model.MediaProduct
16+
import com.tidal.sdk.player.common.model.ProductType
17+
import com.tidal.sdk.player.events.EventReporterModuleRoot
18+
import com.tidal.sdk.player.events.di.DefaultEventReporterComponent
19+
import com.tidal.sdk.player.events.playlogtest.PlayLogTestDefaultEventReporterComponentFactory
20+
import com.tidal.sdk.player.events.reflectionComponentFactoryF
21+
import com.tidal.sdk.player.playbackengine.model.Event
22+
import com.tidal.sdk.player.setBodyFromFile
23+
import kotlin.math.absoluteValue
24+
import kotlin.time.Duration.Companion.minutes
25+
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.flow.Flow
27+
import kotlinx.coroutines.flow.emptyFlow
28+
import kotlinx.coroutines.flow.filter
29+
import kotlinx.coroutines.flow.first
30+
import kotlinx.coroutines.launch
31+
import kotlinx.coroutines.runBlocking
32+
import kotlinx.coroutines.test.StandardTestDispatcher
33+
import kotlinx.coroutines.test.TestCoroutineScheduler
34+
import kotlinx.coroutines.test.TestScope
35+
import kotlinx.coroutines.test.advanceUntilIdle
36+
import kotlinx.coroutines.test.runTest
37+
import kotlinx.coroutines.withContext
38+
import kotlinx.coroutines.withTimeout
39+
import okhttp3.HttpUrl.Companion.toHttpUrl
40+
import okhttp3.OkHttpClient
41+
import okhttp3.mockwebserver.MockResponse
42+
import okhttp3.mockwebserver.MockWebServer
43+
import org.junit.After
44+
import org.junit.Before
45+
import org.junit.BeforeClass
46+
import org.junit.Rule
47+
import org.junit.Test
48+
import org.junit.runners.Parameterized
49+
import org.mockito.Mockito.atMost
50+
import org.mockito.Mockito.mock
51+
import org.mockito.Mockito.verifyNoMoreInteractions
52+
import org.mockito.kotlin.anyOrNull
53+
import org.mockito.kotlin.argThat
54+
import org.mockito.kotlin.eq
55+
import org.mockito.kotlin.verify
56+
57+
internal class TwoMediaProductsPlayLogTest {
58+
59+
@get:Rule
60+
val server = MockWebServer()
61+
62+
private val eventReporterCoroutineScope =
63+
TestScope(StandardTestDispatcher(TestCoroutineScheduler()))
64+
private val responseDispatcher = PlayLogTestMockWebServerDispatcher(server)
65+
private val eventSender = mock<EventSender>()
66+
private val mediaProduct1 = MediaProduct(ProductType.TRACK, "1", "TEST_1", "456")
67+
private val mediaProduct2 = MediaProduct(ProductType.TRACK, "2", "TEST_2", "789")
68+
private lateinit var player: Player
69+
70+
@Before
71+
fun setUp() {
72+
responseDispatcher[
73+
"https://api.tidal.com/v1/tracks/${mediaProduct1.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(),
74+
] = {
75+
MockResponse().setBodyFromFile(
76+
"api-responses/playbackinfo/tracks/playlogtest/get_1_bts.json",
77+
)
78+
}
79+
responseDispatcher["https://test.audio.tidal.com/1_bts.m4a".toHttpUrl()] = {
80+
MockResponse().setBodyFromFile("raw/playlogtest/1_bts.m4a")
81+
}
82+
responseDispatcher[
83+
"https://api.tidal.com/v1/tracks/${mediaProduct2.productId}/playbackinfo?playbackmode=STREAM&assetpresentation=FULL&audioquality=LOW&immersiveaudio=true".toHttpUrl(),
84+
] = {
85+
MockResponse().setBodyFromFile(
86+
"api-responses/playbackinfo/tracks/playlogtest/get_2_bts.json",
87+
)
88+
}
89+
responseDispatcher["https://test.audio.tidal.com/test_1min.m4a".toHttpUrl()] = {
90+
MockResponse().setBodyFromFile("raw/playlogtest/test_1min.m4a")
91+
}
92+
EventReporterModuleRoot.reflectionComponentFactoryF = {
93+
PlayLogTestDefaultEventReporterComponentFactory(eventReporterCoroutineScope)
94+
}
95+
server.dispatcher = responseDispatcher
96+
97+
player = Player(
98+
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
99+
as Application,
100+
object : CredentialsProvider {
101+
private val CREDENTIALS = Credentials(
102+
clientId = "a client id",
103+
requestedScopes = emptySet(),
104+
clientUniqueKey = null,
105+
grantedScopes = emptySet(),
106+
userId = "a non-null user id",
107+
expires = null,
108+
token = "a non-null token",
109+
)
110+
111+
override val bus: Flow<TidalMessage> = emptyFlow()
112+
113+
override suspend fun getCredentials(apiErrorSubStatus: String?) =
114+
AuthResult.Success(CREDENTIALS)
115+
116+
override fun isUserLoggedIn() = CREDENTIALS.isLoggedIn()
117+
},
118+
eventSender = eventSender,
119+
okHttpClient = OkHttpClient.Builder()
120+
.addInterceptor {
121+
val request = it.request()
122+
val mockWebServerUrl = responseDispatcher.urlRecords[request.url]
123+
?: return@addInterceptor it.proceed(request)
124+
it.proceed(
125+
request.newBuilder()
126+
.url(mockWebServerUrl)
127+
.build(),
128+
)
129+
}.build(),
130+
)
131+
}
132+
133+
companion object {
134+
private lateinit var originalEventReporterComponentFactoryF:
135+
() -> DefaultEventReporterComponent.Factory
136+
private const val MEDIA_PRODUCT_1_DURATION_SECONDS = 5.055
137+
private const val MEDIA_PRODUCT_2_DURATION_SECONDS = 60.606667
138+
139+
@BeforeClass
140+
@JvmStatic
141+
fun beforeAll() {
142+
originalEventReporterComponentFactoryF =
143+
EventReporterModuleRoot.reflectionComponentFactoryF
144+
}
145+
146+
@JvmStatic
147+
@Parameterized.Parameters
148+
fun parameters(): List<Array<MediaProduct>> {
149+
val mediaProduct1s = setOf(
150+
MediaProduct(ProductType.TRACK, "1", "TESTA", "456"),
151+
MediaProduct(ProductType.TRACK, "1", null, "789"),
152+
MediaProduct(ProductType.TRACK, "1", "TESTB", null),
153+
MediaProduct(ProductType.TRACK, "1", null, null),
154+
)
155+
val mediaProduct2s = setOf(
156+
MediaProduct(ProductType.TRACK, "2", "TESTA", "456"),
157+
MediaProduct(ProductType.TRACK, "2", null, "789"),
158+
MediaProduct(ProductType.TRACK, "2", "TESTB", null),
159+
MediaProduct(ProductType.TRACK, "2", null, null),
160+
)
161+
return mediaProduct1s.flatMap { mediaProduct1 ->
162+
mediaProduct2s.map { mediaProduct2 ->
163+
arrayOf(mediaProduct1, mediaProduct2)
164+
}
165+
}
166+
}
167+
}
168+
169+
@After
170+
fun afterEach() {
171+
runBlocking {
172+
val job = launch { player.playbackEngine.events.first { it is Event.Release } }
173+
player.release()
174+
job.join()
175+
}
176+
verify(eventSender, atMost(Int.MAX_VALUE))
177+
.sendEvent(
178+
argThat { !contentEquals("playback_session") },
179+
anyOrNull(),
180+
anyOrNull(),
181+
anyOrNull(),
182+
)
183+
verifyNoMoreInteractions(eventSender)
184+
}
185+
186+
@Test
187+
fun playSequentially() = runTest(timeout = 3.minutes) {
188+
val gson = Gson()
189+
190+
player.playbackEngine.load(mediaProduct1)
191+
player.playbackEngine.setNext(mediaProduct2)
192+
player.playbackEngine.play()
193+
withContext(Dispatchers.Default.limitedParallelism(1)) {
194+
withTimeout(2.minutes) {
195+
player.playbackEngine.events.filter { it is Event.MediaProductEnded }.first()
196+
}
197+
}
198+
199+
eventReporterCoroutineScope.advanceUntilIdle()
200+
verify(eventSender).sendEvent(
201+
eq("playback_session"),
202+
eq(ConsentCategory.NECESSARY),
203+
argThat {
204+
with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) {
205+
// https://github.com/androidx/media/issues/1252
206+
get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) &&
207+
// https://github.com/androidx/media/issues/1253
208+
get("endAssetPosition").asDouble
209+
.isAssetPositionEqualTo(MEDIA_PRODUCT_1_DURATION_SECONDS) &&
210+
get("actualProductId")?.asString.contentEquals(mediaProduct1.productId) &&
211+
get("sourceType")?.asString.contentEquals(mediaProduct1.sourceType) &&
212+
get("sourceId")?.asString.contentEquals(mediaProduct1.sourceId) &&
213+
get("actions").asJsonArray.isEmpty
214+
}
215+
},
216+
eq(emptyMap()),
217+
)
218+
verify(eventSender).sendEvent(
219+
eq("playback_session"),
220+
eq(ConsentCategory.NECESSARY),
221+
argThat {
222+
with(gson.fromJson(this, JsonObject::class.java)["payload"].asJsonObject) {
223+
// https://github.com/androidx/media/issues/1252
224+
get("startAssetPosition").asDouble.isAssetPositionEqualTo(0.0) &&
225+
// https://github.com/androidx/media/issues/1253
226+
get("endAssetPosition").asDouble
227+
.isAssetPositionEqualTo(MEDIA_PRODUCT_2_DURATION_SECONDS) &&
228+
get("actualProductId")?.asString.contentEquals(mediaProduct2.productId) &&
229+
get("sourceType")?.asString.contentEquals(mediaProduct2.sourceType) &&
230+
get("sourceId")?.asString.contentEquals(mediaProduct2.sourceId) &&
231+
get("actions").asJsonArray.isEmpty
232+
}
233+
},
234+
eq(emptyMap()),
235+
)
236+
}
237+
238+
private fun Double.isAssetPositionEqualTo(targetPosition: Double) =
239+
(this - targetPosition).absoluteValue < 0.5
240+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"trackId": 2,
3+
"assetPresentation": "FULL",
4+
"audioMode": "STEREO",
5+
"audioQuality": "LOW",
6+
"streamingSessionId": "356",
7+
"manifestMimeType": "application/vnd.tidal.bts",
8+
"manifestHash": "a manifest hash",
9+
"manifest": "eyJtaW1lVHlwZSI6ImF1ZGlvL21wNCIsImNvZGVjcyI6Im1wNGEuNDAuNSIsInVybHMiOlsiaHR0cHM6Ly90ZXN0LmF1ZGlvLnRpZGFsLmNvbS90ZXN0XzFtaW4ubTRhIl19",
10+
"albumReplayGain": -9.8,
11+
"albumPeakAmplitude": 0.999923,
12+
"trackReplayGain": -9.8,
13+
"trackPeakAmplitude": 0.999923,
14+
"bitDepth": 16,
15+
"sampleRate": 44100
16+
}
1.79 MB
Binary file not shown.

0 commit comments

Comments
 (0)