From dfa5281b23aefbaaec325afd714f20b81adc6344 Mon Sep 17 00:00:00 2001 From: Mugurell Date: Wed, 18 May 2022 18:01:03 +0300 Subject: [PATCH] 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. --- .../components/appstate/AppStoreReducer.kt | 17 ++- .../java/org/mozilla/fenix/ext/AppState.kt | 49 ++++++++- .../fenix/home/PocketUpdatesMiddleware.kt | 22 ++-- .../home/pocket/PocketStoriesComposables.kt | 93 +++++++++++++++- .../home/pocket/PocketStoriesController.kt | 9 ++ .../home/pocket/PocketStoriesInteractor.kt | 7 ++ .../home/pocket/PocketStoriesViewHolder.kt | 2 + .../SessionControlInteractor.kt | 4 + .../mozilla/fenix/components/AppStoreTest.kt | 49 ++++++++- .../org/mozilla/fenix/ext/AppStateTest.kt | 103 +++++++++++++++++- .../fenix/home/PocketUpdatesMiddlewareTest.kt | 12 +- .../home/SessionControlInteractorTest.kt | 9 ++ .../DefaultPocketStoriesControllerTest.kt | 16 ++- 13 files changed, 365 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt index ae947134e..57dc4ee06 100644 --- a/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt +++ b/app/src/main/java/org/mozilla/fenix/components/appstate/AppStoreReducer.kt @@ -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().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) diff --git a/app/src/main/java/org/mozilla/fenix/ext/AppState.kt b/app/src/main/java/org/mozilla/fenix/ext/AppState.kt index ed8604060..1c1805eb3 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/AppState.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/AppState.kt @@ -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 { ?.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 { }.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, sponsoredStories: List, ): List { - 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, + limit: Int, +): List { + 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. diff --git a/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt index 9ae4b9f92..255dbe87d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/home/PocketUpdatesMiddleware.kt @@ -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() - .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 + updatedStories: List ) { coroutineScope.launch { pocketStoriesService.updateStoriesTimesShown( - updatedStories + updatedStories.filterIsInstance() + .map { + it.copy(timesShown = it.timesShown.inc()) + } + ) + + pocketStoriesService.recordStoriesImpressions( + updatedStories.filterIsInstance() + .map { it.id } ) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesComposables.kt b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesComposables.kt index 856548437..386a3cf22 100644 --- a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesComposables.kt +++ b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesComposables.kt @@ -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, contentPadding: Dp, + onStoryShown: (PocketStory) -> Unit, onStoryClicked: (PocketStory, Pair) -> 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 { ) 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, + ) ) ) } diff --git a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesController.kt b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesController.kt index 489b843c2..b3ce23b39 100644 --- a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesController.kt @@ -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) { appStore.dispatch(AppAction.PocketStoriesShown(storiesShown)) Pocket.homeRecsShown.record(NoExtras()) diff --git a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesInteractor.kt index 157ed0e13..416b899af 100644 --- a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesInteractor.kt @@ -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. * diff --git a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesViewHolder.kt index 59168c240..a64050578 100644 --- a/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesViewHolder.kt @@ -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 = {} ) diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index b67c5a5fb..fb39bd17b 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -435,6 +435,10 @@ class SessionControlInteractor( controller.handleCustomizeHomeTapped() } + override fun onStoryShown(storyShown: PocketStory) { + pocketStoriesController.handleStoryShown(storyShown) + } + override fun onStoriesShown(storiesShown: List) { pocketStoriesController.handleStoriesShown(storiesShown) } diff --git a/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt b/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt index a35be8e12..b9fb6e538 100644 --- a/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/AppStoreTest.kt @@ -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") diff --git a/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt b/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt index 4c33234ae..59cbf6d56 100644 --- a/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt +++ b/app/src/test/java/org/mozilla/fenix/ext/AppStateTest.kt @@ -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 = 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 diff --git a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt index d9682caa7..ec8104be7 100644 --- a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt @@ -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 = mockk() diff --git a/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt b/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt index 711fcb6bf..355e79835 100644 --- a/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/pocket/DefaultPocketStoriesControllerTest.kt @@ -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))