For #25281 - Pace and rotate sponsored stories
A new way to be able to reliably record actual impressions of sponsored stories was needed and based on this data we can ensure we are promoting fresh stories (with fewer impressions) or the ones with a higher priority.
This commit is contained in:
parent
2b777c3428
commit
dfa5281b23
|
@ -6,6 +6,8 @@ package org.mozilla.fenix.components.appstate
|
|||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.ext.recordNewImpression
|
||||
import org.mozilla.fenix.components.AppStore
|
||||
import org.mozilla.fenix.ext.filterOutTab
|
||||
import org.mozilla.fenix.ext.getFilteredStories
|
||||
|
@ -171,7 +173,20 @@ internal object AppStoreReducer {
|
|||
}
|
||||
}
|
||||
|
||||
state.copy(pocketStoriesCategories = updatedCategories)
|
||||
var updatedSponsoredStories = state.pocketSponsoredStories
|
||||
action.storiesShown.filterIsInstance<PocketSponsoredStory>().forEach { shownStory ->
|
||||
updatedSponsoredStories = updatedSponsoredStories.map { story ->
|
||||
when (story.id == shownStory.id) {
|
||||
true -> story.recordNewImpression()
|
||||
false -> story
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.copy(
|
||||
pocketStoriesCategories = updatedCategories,
|
||||
pocketSponsoredStories = updatedSponsoredStories
|
||||
)
|
||||
}
|
||||
is AppAction.AddPendingDeletionSet ->
|
||||
state.copy(pendingDeletionHistoryItems = state.pendingDeletionHistoryItems + action.historyItems)
|
||||
|
|
|
@ -8,6 +8,8 @@ import androidx.annotation.VisibleForTesting
|
|||
import mozilla.components.service.pocket.PocketStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.ext.hasFlightImpressionsLimitReached
|
||||
import mozilla.components.service.pocket.ext.hasLifetimeImpressionsLimitReached
|
||||
import org.mozilla.fenix.components.appstate.AppState
|
||||
import org.mozilla.fenix.home.blocklist.BlocklistHandler
|
||||
import org.mozilla.fenix.home.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
||||
|
@ -15,9 +17,20 @@ import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
|
|||
import org.mozilla.fenix.home.pocket.PocketStory
|
||||
import org.mozilla.fenix.home.recenttabs.RecentTab.SearchGroup
|
||||
|
||||
/**
|
||||
* Total count of all stories to show irrespective of their type.
|
||||
* This is an optimistic value taking into account that fewer than this stories may actually be available.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal const val POCKET_STORIES_TO_SHOW_COUNT = 8
|
||||
|
||||
/**
|
||||
* Total count of all sponsored Pocket stories to show.
|
||||
* This is an optimistic value taking into account that fewer than this stories may actually be available.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal const val POCKET_SPONSORED_STORIES_TO_SHOW_COUNT = 2
|
||||
|
||||
/**
|
||||
* Get the list of stories to be displayed based on the user selected categories.
|
||||
*
|
||||
|
@ -32,9 +45,14 @@ fun AppState.getFilteredStories(): List<PocketStory> {
|
|||
?.sortedBy { it.timesShown }
|
||||
?.take(POCKET_STORIES_TO_SHOW_COUNT) ?: emptyList()
|
||||
|
||||
val sponsoredStories = getFilteredSponsoredStories(
|
||||
stories = pocketSponsoredStories,
|
||||
limit = POCKET_SPONSORED_STORIES_TO_SHOW_COUNT,
|
||||
)
|
||||
|
||||
return combineRecommendedAndSponsoredStories(
|
||||
recommendedStories = recommendedStories,
|
||||
sponsoredStories = pocketSponsoredStories,
|
||||
sponsoredStories = sponsoredStories
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -56,12 +74,21 @@ fun AppState.getFilteredStories(): List<PocketStory> {
|
|||
}.take(POCKET_STORIES_TO_SHOW_COUNT)
|
||||
}
|
||||
|
||||
private fun combineRecommendedAndSponsoredStories(
|
||||
/**
|
||||
* Combine all available Pocket recommended and sponsored stories to show at max [POCKET_STORIES_TO_SHOW_COUNT]
|
||||
* stories of both types but based on a specific split.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun combineRecommendedAndSponsoredStories(
|
||||
recommendedStories: List<PocketRecommendedStory>,
|
||||
sponsoredStories: List<PocketSponsoredStory>,
|
||||
): List<PocketStory> {
|
||||
val recommendedStoriesToShow = POCKET_STORIES_TO_SHOW_COUNT - sponsoredStories.size.coerceAtMost(2)
|
||||
val recommendedStoriesToShow =
|
||||
POCKET_STORIES_TO_SHOW_COUNT - sponsoredStories.size.coerceAtMost(
|
||||
POCKET_SPONSORED_STORIES_TO_SHOW_COUNT
|
||||
)
|
||||
|
||||
// Sponsored stories should be shown at position 2 and 8. If possible.
|
||||
return recommendedStories.take(1) +
|
||||
sponsoredStories.take(1) +
|
||||
recommendedStories.take(recommendedStoriesToShow).drop(1) +
|
||||
|
@ -113,6 +140,22 @@ internal fun getFilteredStoriesCount(
|
|||
return emptyMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pacing and rotation of sponsored stories.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun getFilteredSponsoredStories(
|
||||
stories: List<PocketSponsoredStory>,
|
||||
limit: Int,
|
||||
): List<PocketSponsoredStory> {
|
||||
return stories.asSequence()
|
||||
.filterNot { it.hasLifetimeImpressionsLimitReached() }
|
||||
.sortedByDescending { it.priority }
|
||||
.filterNot { it.hasFlightImpressionsLimitReached() }
|
||||
.take(limit)
|
||||
.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the [SearchGroup] shown in the "Jump back in" section.
|
||||
* May be null if no search group is shown.
|
||||
|
|
|
@ -15,7 +15,9 @@ import mozilla.components.lib.state.Middleware
|
|||
import mozilla.components.lib.state.MiddlewareContext
|
||||
import mozilla.components.lib.state.Store
|
||||
import mozilla.components.service.pocket.PocketStoriesService
|
||||
import mozilla.components.service.pocket.PocketStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import org.mozilla.fenix.components.AppStore
|
||||
import org.mozilla.fenix.components.appstate.AppAction
|
||||
import org.mozilla.fenix.components.appstate.AppState
|
||||
|
@ -65,14 +67,10 @@ class PocketUpdatesMiddleware(
|
|||
// Post process actions
|
||||
when (action) {
|
||||
is AppAction.PocketStoriesShown -> {
|
||||
persistStories(
|
||||
persistStoriesImpressions(
|
||||
coroutineScope = coroutineScope,
|
||||
pocketStoriesService = pocketStoriesService,
|
||||
updatedStories = action.storiesShown
|
||||
.filterIsInstance<PocketRecommendedStory>()
|
||||
.map {
|
||||
it.copy(timesShown = it.timesShown.inc())
|
||||
}
|
||||
)
|
||||
}
|
||||
is AppAction.SelectPocketStoriesCategory,
|
||||
|
@ -98,14 +96,22 @@ class PocketUpdatesMiddleware(
|
|||
* @param updatedStories the list of stories to persist.
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun persistStories(
|
||||
internal fun persistStoriesImpressions(
|
||||
coroutineScope: CoroutineScope,
|
||||
pocketStoriesService: PocketStoriesService,
|
||||
updatedStories: List<PocketRecommendedStory>
|
||||
updatedStories: List<PocketStory>
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
pocketStoriesService.updateStoriesTimesShown(
|
||||
updatedStories
|
||||
updatedStories.filterIsInstance<PocketRecommendedStory>()
|
||||
.map {
|
||||
it.copy(timesShown = it.timesShown.inc())
|
||||
}
|
||||
)
|
||||
|
||||
pocketStoriesService.recordStoriesImpressions(
|
||||
updatedStories.filterIsInstance<PocketSponsoredStory>()
|
||||
.map { it.id }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,10 @@
|
|||
|
||||
package org.mozilla.fenix.home.pocket
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
@ -23,9 +26,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toAndroidRect
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
@ -40,6 +53,7 @@ import androidx.compose.ui.unit.sp
|
|||
import mozilla.components.service.pocket.PocketStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryShim
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.compose.ClickableSubstringLink
|
||||
|
@ -54,6 +68,8 @@ import org.mozilla.fenix.compose.TabSubtitleWithInterdot
|
|||
import org.mozilla.fenix.compose.SecondaryText
|
||||
import org.mozilla.fenix.theme.FirefoxTheme
|
||||
import org.mozilla.fenix.theme.Theme
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val URI_PARAM_UTM_KEY = "utm_source"
|
||||
|
@ -189,6 +205,7 @@ fun PocketSponsoredStory(
|
|||
fun PocketStories(
|
||||
@PreviewParameter(PocketStoryProvider::class) stories: List<PocketStory>,
|
||||
contentPadding: Dp,
|
||||
onStoryShown: (PocketStory) -> Unit,
|
||||
onStoryClicked: (PocketStory, Pair<Int, Int>) -> Unit,
|
||||
onDiscoverMoreClicked: (String) -> Unit
|
||||
) {
|
||||
|
@ -221,8 +238,14 @@ fun PocketStories(
|
|||
onStoryClicked(it.copy(url = uri), rowIndex to columnIndex)
|
||||
}
|
||||
} else if (story is PocketSponsoredStory) {
|
||||
PocketSponsoredStory(story) {
|
||||
onStoryClicked(story, rowIndex to columnIndex)
|
||||
Box(
|
||||
modifier = Modifier.onShown(0.5f) {
|
||||
onStoryShown(story)
|
||||
}
|
||||
) {
|
||||
PocketSponsoredStory(story) {
|
||||
onStoryClicked(story, rowIndex to columnIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -231,6 +254,62 @@ fun PocketStories(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback for when this Composable is "shown" on the screen.
|
||||
* This checks whether the composable has at least [threshold] ratio of it's total area drawn inside
|
||||
* the screen bounds.
|
||||
* Does not account for other Views / Windows covering it.
|
||||
*/
|
||||
private fun Modifier.onShown(
|
||||
@FloatRange(from = 0.0, to = 1.0) threshold: Float,
|
||||
onVisible: () -> Unit,
|
||||
): Modifier {
|
||||
return composed {
|
||||
val context = LocalContext.current
|
||||
var wasEventReported by remember { mutableStateOf(false) }
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
if (!wasEventReported && coordinates.isVisible(context, threshold)) {
|
||||
wasEventReported = true
|
||||
onVisible()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether this has at least [threshold] ratio of it's total area drawn inside
|
||||
* the screen bounds.
|
||||
*/
|
||||
private fun LayoutCoordinates.isVisible(
|
||||
context: Context,
|
||||
@FloatRange(from = 0.0, to = 1.0) threshold: Float,
|
||||
): Boolean {
|
||||
if (!isAttached) return false
|
||||
|
||||
val screenBounds = Rect(
|
||||
/* left = */0,
|
||||
/* top = */0,
|
||||
/* right = */context.resources.displayMetrics.widthPixels,
|
||||
/* bottom = */context.resources.displayMetrics.heightPixels
|
||||
)
|
||||
|
||||
return boundsInWindow().toAndroidRect().getIntersectPercentage(screenBounds) >= threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ratio of how much this intersects with [other].
|
||||
*/
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
private fun Rect.getIntersectPercentage(other: Rect): Float {
|
||||
val composableArea = height() * width()
|
||||
val heightOverlap = max(0, min(bottom, other.bottom) - max(top, other.top))
|
||||
val widthOverlap = max(0, min(right, other.right) - max(left, other.left))
|
||||
val intersectionArea = heightOverlap * widthOverlap
|
||||
|
||||
return (intersectionArea.toFloat() / composableArea)
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of [PocketRecommendedStoriesCategory]s.
|
||||
*
|
||||
|
@ -327,6 +406,7 @@ private fun PocketStoriesComposablesPreview() {
|
|||
PocketStories(
|
||||
stories = getFakePocketStories(8),
|
||||
contentPadding = 0.dp,
|
||||
onStoryShown = {},
|
||||
onStoryClicked = { _, _ -> },
|
||||
onDiscoverMoreClicked = {}
|
||||
)
|
||||
|
@ -371,11 +451,18 @@ internal fun getFakePocketStories(limit: Int = 1): List<PocketStory> {
|
|||
)
|
||||
false -> add(
|
||||
PocketSponsoredStory(
|
||||
id = index,
|
||||
title = "This is a ${"very ".repeat(index)} long title",
|
||||
url = "https://sponsored-story$index.com",
|
||||
imageUrl = "",
|
||||
sponsor = "Mozilla",
|
||||
shim = PocketSponsoredStoryShim("", "")
|
||||
shim = PocketSponsoredStoryShim("", ""),
|
||||
priority = index,
|
||||
caps = PocketSponsoredStoryCaps(
|
||||
flightCount = index,
|
||||
flightPeriod = index * 2,
|
||||
lifetimeCount = index * 3,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -20,6 +20,11 @@ import org.mozilla.fenix.components.appstate.AppAction
|
|||
* Contract for how all user interactions with the Pocket stories feature are to be handled.
|
||||
*/
|
||||
interface PocketStoriesController {
|
||||
/**
|
||||
* Callback to decide what should happen as an effect of a specific story being shown.
|
||||
*/
|
||||
fun handleStoryShown(storyShown: PocketStory)
|
||||
|
||||
/**
|
||||
* Callback to decide what should happen as an effect of a new list of stories being shown.
|
||||
*
|
||||
|
@ -69,6 +74,10 @@ internal class DefaultPocketStoriesController(
|
|||
private val appStore: AppStore,
|
||||
private val navController: NavController,
|
||||
) : PocketStoriesController {
|
||||
override fun handleStoryShown(storyShown: PocketStory) {
|
||||
appStore.dispatch(AppAction.PocketStoriesShown(listOf(storyShown)))
|
||||
}
|
||||
|
||||
override fun handleStoriesShown(storiesShown: List<PocketStory>) {
|
||||
appStore.dispatch(AppAction.PocketStoriesShown(storiesShown))
|
||||
Pocket.homeRecsShown.record(NoExtras())
|
||||
|
|
|
@ -11,6 +11,13 @@ import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
|||
* Contract for all possible user interactions with the Pocket recommended stories feature.
|
||||
*/
|
||||
interface PocketStoriesInteractor {
|
||||
/**
|
||||
* Callback for when a certain story is shown to the user.
|
||||
*
|
||||
* @param storyShown The story shown to the user.
|
||||
*/
|
||||
fun onStoryShown(storyShown: PocketStory)
|
||||
|
||||
/**
|
||||
* Callback for then new stories are shown to the user.
|
||||
*
|
||||
|
|
|
@ -77,6 +77,7 @@ class PocketStoriesViewHolder(
|
|||
PocketStories(
|
||||
stories ?: emptyList(),
|
||||
horizontalPadding,
|
||||
interactor::onStoryShown,
|
||||
interactor::onStoryClicked,
|
||||
interactor::onDiscoverMoreClicked
|
||||
)
|
||||
|
@ -103,6 +104,7 @@ fun PocketStoriesViewHolderPreview() {
|
|||
PocketStories(
|
||||
stories = getFakePocketStories(8),
|
||||
contentPadding = 0.dp,
|
||||
onStoryShown = {},
|
||||
onStoryClicked = { _, _ -> },
|
||||
onDiscoverMoreClicked = {}
|
||||
)
|
||||
|
|
|
@ -435,6 +435,10 @@ class SessionControlInteractor(
|
|||
controller.handleCustomizeHomeTapped()
|
||||
}
|
||||
|
||||
override fun onStoryShown(storyShown: PocketStory) {
|
||||
pocketStoriesController.handleStoryShown(storyShown)
|
||||
}
|
||||
|
||||
override fun onStoriesShown(storiesShown: List<PocketStory>) {
|
||||
pocketStoriesController.handleStoriesShown(storiesShown)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ import mozilla.components.feature.top.sites.TopSite
|
|||
import mozilla.components.service.fxa.manager.FxaAccountManager
|
||||
import mozilla.components.service.pocket.PocketStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
|
@ -364,7 +365,16 @@ class AppStoreTest {
|
|||
|
||||
@Test
|
||||
fun `Test updating the list of Pocket sponsored stories`() = runTest {
|
||||
val story1 = PocketSponsoredStory("title", "url", "imageUrl", "sponsor", mockk())
|
||||
val story1 = PocketSponsoredStory(
|
||||
id = 3,
|
||||
title = "title",
|
||||
url = "url",
|
||||
imageUrl = "imageUrl",
|
||||
sponsor = "sponsor",
|
||||
shim = mockk(),
|
||||
priority = 33,
|
||||
caps = mockk(),
|
||||
)
|
||||
val story2 = story1.copy(imageUrl = "imageUrl2")
|
||||
|
||||
appStore = AppStore(AppState())
|
||||
|
@ -373,11 +383,46 @@ class AppStoreTest {
|
|||
.join()
|
||||
assertTrue(appStore.state.pocketSponsoredStories.containsAll(listOf(story1, story2)))
|
||||
|
||||
val updatedStories = listOf(story2.copy("title3"))
|
||||
val updatedStories = listOf(story2.copy(title = "title3"))
|
||||
appStore.dispatch(AppAction.PocketSponsoredStoriesChange(updatedStories)).join()
|
||||
assertTrue(updatedStories.containsAll(appStore.state.pocketSponsoredStories))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test updating sponsored Pocket stories after being shown to the user`() = runTest {
|
||||
val story1 = PocketSponsoredStory(
|
||||
id = 3,
|
||||
title = "title",
|
||||
url = "url",
|
||||
imageUrl = "imageUrl",
|
||||
sponsor = "sponsor",
|
||||
shim = mockk(),
|
||||
priority = 33,
|
||||
caps = PocketSponsoredStoryCaps(
|
||||
currentImpressions = listOf(1, 2),
|
||||
lifetimeCount = 11,
|
||||
flightCount = 2,
|
||||
flightPeriod = 11
|
||||
),
|
||||
)
|
||||
val story2 = story1.copy(id = 22)
|
||||
val story3 = story1.copy(id = 33)
|
||||
val story4 = story1.copy(id = 44)
|
||||
appStore = AppStore(
|
||||
AppState(
|
||||
pocketSponsoredStories = listOf(story1, story2, story3, story4)
|
||||
)
|
||||
)
|
||||
|
||||
appStore.dispatch(AppAction.PocketStoriesShown(listOf(story1, story3))).join()
|
||||
|
||||
assertEquals(4, appStore.state.pocketSponsoredStories.size)
|
||||
assertEquals(3, appStore.state.pocketSponsoredStories[0].caps.currentImpressions.size)
|
||||
assertEquals(2, appStore.state.pocketSponsoredStories[1].caps.currentImpressions.size)
|
||||
assertEquals(3, appStore.state.pocketSponsoredStories[2].caps.currentImpressions.size)
|
||||
assertEquals(2, appStore.state.pocketSponsoredStories[3].caps.currentImpressions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Test updating the list of Pocket recommendations categories`() = runTest {
|
||||
val otherStoriesCategory = PocketRecommendedStoriesCategory("other")
|
||||
|
|
|
@ -7,6 +7,7 @@ package org.mozilla.fenix.ext
|
|||
import io.mockk.mockk
|
||||
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryCaps
|
||||
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStoryShim
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
|
@ -18,6 +19,7 @@ import org.mozilla.fenix.home.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
|||
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
|
||||
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
|
||||
import org.mozilla.fenix.home.recenttabs.RecentTab
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
class AppStateTest {
|
||||
|
@ -96,7 +98,7 @@ class AppStateTest {
|
|||
POCKET_STORIES_DEFAULT_CATEGORY_NAME,
|
||||
getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT)
|
||||
)
|
||||
val sponsoredStories = getFakeSponsoredStories(2)
|
||||
val sponsoredStories = getFakeSponsoredStories(4)
|
||||
val state = AppState(
|
||||
pocketStoriesCategories = listOf(
|
||||
otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategoryWithManyStories
|
||||
|
@ -108,9 +110,8 @@ class AppStateTest {
|
|||
|
||||
assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
|
||||
// second story should be a sponsored one
|
||||
assertEquals(sponsoredStories[0], result[1])
|
||||
// last story should be a sponsored one
|
||||
assertEquals(sponsoredStories[1], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
|
||||
assertEquals(sponsoredStories[1], result[1])
|
||||
assertEquals(sponsoredStories[3], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
|
||||
// remove the sponsored stories to hopefully only remain with general recommendations
|
||||
result.removeAt(7)
|
||||
result.removeAt(1)
|
||||
|
@ -121,6 +122,91 @@ class AppStateTest {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a list of sponsored stories WHEN filtering them THEN have them ordered by priority`() {
|
||||
val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
|
||||
story.copy(priority = index)
|
||||
}
|
||||
|
||||
val result = getFilteredSponsoredStories(stories, 10)
|
||||
|
||||
assertEquals(4, result.size)
|
||||
assertEquals(stories.reversed(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a list of sponsored stories WHEN filtering them THEN drop the ones already shown for the maximum number of times in lifetime`() {
|
||||
val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
|
||||
when (index % 2 == 0) {
|
||||
true -> story.copy(
|
||||
caps = story.caps.copy(
|
||||
currentImpressions = listOf(1, 2, 3),
|
||||
lifetimeCount = 3
|
||||
)
|
||||
)
|
||||
false -> story
|
||||
}
|
||||
}
|
||||
|
||||
val result = getFilteredSponsoredStories(stories, 10)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(stories[1], result[0])
|
||||
assertEquals(stories[3], result[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a list of sponsored stories WHEN filtering them THEN drop the ones already shown for the maximum number of times in flight`() {
|
||||
val stories = getFakeSponsoredStories(4).mapIndexed { index, story ->
|
||||
when (index % 2 == 0) {
|
||||
true -> story
|
||||
false -> story.copy(
|
||||
caps = story.caps.copy(
|
||||
currentImpressions = listOf(
|
||||
TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
|
||||
TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()),
|
||||
TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
|
||||
),
|
||||
flightCount = 3
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val result = getFilteredSponsoredStories(stories, 10)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
assertEquals(stories[0], result[0])
|
||||
assertEquals(stories[2], result[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a list of sponsored stories WHEN filtering them THEN return up to limit of stories asked`() {
|
||||
val stories = getFakeSponsoredStories(4)
|
||||
|
||||
val result = getFilteredSponsoredStories(stories, 2)
|
||||
|
||||
assertEquals(2, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple stories of both types WHEN combining them THEN show sponsored stories at positionn 2 and 8`() {
|
||||
val recommendedStories = getFakePocketStories(POCKET_STORIES_TO_SHOW_COUNT, "other")
|
||||
val sponsoredStories = getFakeSponsoredStories(4)
|
||||
|
||||
val result = combineRecommendedAndSponsoredStories(recommendedStories, sponsoredStories)
|
||||
|
||||
assertEquals(POCKET_STORIES_TO_SHOW_COUNT, result.size)
|
||||
assertEquals(recommendedStories[0], result[0])
|
||||
assertEquals(sponsoredStories[0], result[1])
|
||||
assertEquals(recommendedStories[1], result[2])
|
||||
assertEquals(recommendedStories[2], result[3])
|
||||
assertEquals(recommendedStories[3], result[4])
|
||||
assertEquals(recommendedStories[4], result[5])
|
||||
assertEquals(recommendedStories[5], result[6])
|
||||
assertEquals(sponsoredStories[1], result[POCKET_STORIES_TO_SHOW_COUNT - 1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a category is selected WHEN getFilteredStories is called THEN only stories from that category are returned`() {
|
||||
val state = AppState(
|
||||
|
@ -384,6 +470,7 @@ private fun getFakeSponsoredStories(limit: Int) = mutableListOf<PocketSponsoredS
|
|||
for (index in 0 until limit) {
|
||||
add(
|
||||
PocketSponsoredStory(
|
||||
id = index,
|
||||
title = "Story title $index",
|
||||
url = "https://sponsored.story",
|
||||
imageUrl = "https://sponsored.image",
|
||||
|
@ -391,7 +478,13 @@ private fun getFakeSponsoredStories(limit: Int) = mutableListOf<PocketSponsoredS
|
|||
shim = PocketSponsoredStoryShim(
|
||||
click = "Story title $index click shim",
|
||||
impression = "Story title $index impression shim"
|
||||
)
|
||||
),
|
||||
priority = 2 + index % 2,
|
||||
caps = PocketSponsoredStoryCaps(
|
||||
lifetimeCount = 1 + index * 5,
|
||||
flightCount = 1 + index * 2,
|
||||
flightPeriod = 1 + index * 3,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -54,17 +54,21 @@ class PocketUpdatesMiddlewareTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN persistStories is called THEN update PocketStoriesService`() = runTestOnMain {
|
||||
val stories: List<PocketRecommendedStory> = mockk()
|
||||
fun `WHEN needing to persist impressions is called THEN update PocketStoriesService`() = runTestOnMain {
|
||||
val story = PocketRecommendedStory(
|
||||
"title", "url1", "imageUrl", "publisher", "category", 0, timesShown = 3
|
||||
)
|
||||
val stories = listOf(story)
|
||||
val expectedStoryUpdate = story.copy(timesShown = story.timesShown.inc())
|
||||
val pocketService: PocketStoriesService = mockk(relaxed = true)
|
||||
|
||||
persistStories(
|
||||
persistStoriesImpressions(
|
||||
coroutineScope = this,
|
||||
pocketStoriesService = pocketService,
|
||||
updatedStories = stories
|
||||
)
|
||||
|
||||
coVerify { pocketService.updateStoriesTimesShown(stories) }
|
||||
coVerify { pocketService.updateStoriesTimesShown(listOf(expectedStoryUpdate)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -237,6 +237,15 @@ class SessionControlInteractorTest {
|
|||
verify { controller.handleSponsorPrivacyClicked() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a PocketStoriesInteractor WHEN a story is shown THEN handle it in a PocketStoriesController`() {
|
||||
val shownStory: PocketStory = mockk()
|
||||
|
||||
interactor.onStoryShown(shownStory)
|
||||
|
||||
verify { pocketStoriesController.handleStoryShown(shownStory) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a PocketStoriesInteractor WHEN stories are shown THEN handle it in a PocketStoriesController`() {
|
||||
val shownStories: List<PocketStory> = mockk()
|
||||
|
|
|
@ -144,6 +144,17 @@ class DefaultPocketStoriesControllerTest {
|
|||
assertEquals("7", event.single().extra!!["selected_total"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN a new story is shown THEN update the State`() {
|
||||
val store = spyk(AppStore())
|
||||
val controller = DefaultPocketStoriesController(mockk(), store, mockk())
|
||||
val storyShown: PocketStory = mockk()
|
||||
|
||||
controller.handleStoryShown(storyShown)
|
||||
|
||||
verify { store.dispatch(AppAction.PocketStoriesShown(listOf(storyShown))) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN new stories are shown THEN update the State and record telemetry`() {
|
||||
val store = spyk(AppStore())
|
||||
|
@ -190,11 +201,14 @@ class DefaultPocketStoriesControllerTest {
|
|||
@Test
|
||||
fun `WHEN a sponsored story is clicked THEN open that story's url using HomeActivity and don't record telemetry`() {
|
||||
val story = PocketSponsoredStory(
|
||||
id = 7,
|
||||
title = "",
|
||||
url = "testLink",
|
||||
imageUrl = "",
|
||||
sponsor = "",
|
||||
shim = mockk()
|
||||
shim = mockk(),
|
||||
priority = 3,
|
||||
caps = mockk(),
|
||||
)
|
||||
val homeActivity: HomeActivity = mockk(relaxed = true)
|
||||
val controller = DefaultPocketStoriesController(homeActivity, mockk(), mockk(relaxed = true))
|
||||
|
|
Loading…
Reference in New Issue
Block a user