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))