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.
pull/543/head
Mugurell 2 years ago committed by mergify[bot]
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…
Cancel
Save