For #21045: Add categories support
parent
ccc0f17e4f
commit
ba4c44afcf
@ -0,0 +1,109 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.ext
|
||||||
|
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import mozilla.components.service.pocket.PocketRecommendedStory
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentState
|
||||||
|
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
||||||
|
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of stories to be displayed.
|
||||||
|
* Either the stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] either
|
||||||
|
* filtered stories based on the user selected categories.
|
||||||
|
*
|
||||||
|
* @param neededStoriesCount how many stories are intended to be displayed.
|
||||||
|
* This only impacts filtered results guaranteeing an even spread of stories from each category.
|
||||||
|
*
|
||||||
|
* @return a list of [PocketRecommendedStory]es from the currently selected categories
|
||||||
|
* topped if necessary with stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount].
|
||||||
|
*/
|
||||||
|
fun HomeFragmentState.getFilteredStories(
|
||||||
|
neededStoriesCount: Int
|
||||||
|
): List<PocketRecommendedStory> {
|
||||||
|
val currentlySelectedCategories = pocketStoriesCategories.filter { it.isSelected }
|
||||||
|
|
||||||
|
if (currentlySelectedCategories.isEmpty()) {
|
||||||
|
return pocketStoriesCategories
|
||||||
|
.find {
|
||||||
|
it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
||||||
|
}?.stories?.take(neededStoriesCount) ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val oldestSortedCategories = currentlySelectedCategories
|
||||||
|
.sortedBy { it.lastInteractedWithTimestamp }
|
||||||
|
|
||||||
|
val filteredStoriesCount = getFilteredStoriesCount(
|
||||||
|
pocketStoriesCategories, oldestSortedCategories, neededStoriesCount
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add general stories at the end of the stories list to show until neededStoriesCount
|
||||||
|
val generalStoriesTopup = filteredStoriesCount[POCKET_STORIES_DEFAULT_CATEGORY_NAME]?.let { neededTopups ->
|
||||||
|
pocketStoriesCategories.find { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }?.stories?.take(neededTopups)
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
return oldestSortedCategories
|
||||||
|
.flatMap { it.stories.take(filteredStoriesCount[it.name]!!) }
|
||||||
|
.plus(generalStoriesTopup)
|
||||||
|
.take(neededStoriesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get how many stories needs to be shown from each currently selected category.
|
||||||
|
*
|
||||||
|
* If the selected categories together don't have [neededStoriesCount] stories then
|
||||||
|
* the difference is added from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] category.
|
||||||
|
*
|
||||||
|
* @param allCategories the list of all Pocket stories categories.
|
||||||
|
* @param selectedCategories ordered list of categories from which to return results.
|
||||||
|
* @param neededStoriesCount how many stories are intended to be displayed.
|
||||||
|
* This impacts the results by guaranteeing an even spread of stories from each category in that stories count.
|
||||||
|
*
|
||||||
|
* @return a mapping of how many stories are to be shown from each category from [selectedCategories].
|
||||||
|
* The result is topped with stories counts from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount].
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
@Suppress("ReturnCount", "NestedBlockDepth")
|
||||||
|
internal fun getFilteredStoriesCount(
|
||||||
|
allCategories: List<PocketRecommendedStoryCategory>,
|
||||||
|
selectedCategories: List<PocketRecommendedStoryCategory>,
|
||||||
|
neededStoriesCount: Int
|
||||||
|
): Map<String, Int> {
|
||||||
|
val totalStoriesInFilteredCategories = selectedCategories.fold(0) { availableStories, category ->
|
||||||
|
availableStories + category.stories.size
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
totalStoriesInFilteredCategories == neededStoriesCount -> {
|
||||||
|
return selectedCategories.map { it.name to it.stories.size }.toMap()
|
||||||
|
}
|
||||||
|
totalStoriesInFilteredCategories < neededStoriesCount -> {
|
||||||
|
return selectedCategories.map { it.name to it.stories.size }.toMap() +
|
||||||
|
allCategories.filter { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.map {
|
||||||
|
it.name to (neededStoriesCount - totalStoriesInFilteredCategories).coerceAtMost(it.stories.size)
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val storiesCountFromEachCategory = mutableMapOf<String, Int>()
|
||||||
|
var currentFilteredStoriesCount = 0
|
||||||
|
|
||||||
|
for (i in 0 until selectedCategories.maxOf { it.stories.size }) {
|
||||||
|
selectedCategories.forEach { category ->
|
||||||
|
if (category.stories.getOrNull(i) != null) {
|
||||||
|
storiesCountFromEachCategory[category.name] =
|
||||||
|
storiesCountFromEachCategory[category.name]?.inc() ?: 1
|
||||||
|
|
||||||
|
if (++currentFilteredStoriesCount == neededStoriesCount) {
|
||||||
|
return storiesCountFromEachCategory
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyMap()
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
|
||||||
|
|
||||||
|
import mozilla.components.service.pocket.PocketRecommendedStory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Category name of the default category from which stories are to be shown
|
||||||
|
* if user hasn't explicitly selected others.
|
||||||
|
*/
|
||||||
|
const val POCKET_STORIES_DEFAULT_CATEGORY_NAME = "general"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pocket assigned topic of interest for each story.
|
||||||
|
*
|
||||||
|
* One to many relationship with [PocketRecommendedStory]es.
|
||||||
|
*
|
||||||
|
* @property name The exact name of each category. Case sensitive.
|
||||||
|
* @property stories All [PocketRecommendedStory]es with this category.
|
||||||
|
* @property isSelected Whether this category is currently selected by the user.
|
||||||
|
* @property lastInteractedWithTimestamp Last time the user selected or deselected this category.
|
||||||
|
*/
|
||||||
|
data class PocketRecommendedStoryCategory(
|
||||||
|
val name: String,
|
||||||
|
val stories: List<PocketRecommendedStory> = emptyList(),
|
||||||
|
val isSelected: Boolean = false,
|
||||||
|
val lastInteractedWithTimestamp: Long = 0L
|
||||||
|
)
|
@ -0,0 +1,65 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
|
||||||
|
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentAction
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentStore
|
||||||
|
import mozilla.components.lib.state.Store
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for how all user interactions with the Pocket recommended stories feature are to be handled.
|
||||||
|
*/
|
||||||
|
interface PocketStoriesController {
|
||||||
|
/**
|
||||||
|
* Callback allowing to handle a specific [PocketRecommendedStoryCategory] being clicked by the user.
|
||||||
|
*
|
||||||
|
* @param categoryClicked the just clicked [PocketRecommendedStoryCategory].
|
||||||
|
*/
|
||||||
|
fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory): Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default behavior for handling all user interactions with the Pocket recommended stories feature.
|
||||||
|
*
|
||||||
|
* @param homeStore [Store] from which to read the current Pocket recommendations and dispatch new actions on.
|
||||||
|
*/
|
||||||
|
internal class DefaultPocketStoriesController(
|
||||||
|
val homeStore: HomeFragmentStore
|
||||||
|
) : PocketStoriesController {
|
||||||
|
override fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory) {
|
||||||
|
val allCategories = homeStore.state.pocketStoriesCategories
|
||||||
|
|
||||||
|
// First check whether the category is clicked to be deselected.
|
||||||
|
if (categoryClicked.isSelected) {
|
||||||
|
homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categoryClicked.name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a new category is clicked to be selected:
|
||||||
|
// Ensure the number of categories selected at a time is capped.
|
||||||
|
val currentlySelectedCategoriesCount = allCategories.fold(0) { count, category ->
|
||||||
|
if (category.isSelected) count + 1 else count
|
||||||
|
}
|
||||||
|
val oldestCategoryToDeselect =
|
||||||
|
if (currentlySelectedCategoriesCount == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT) {
|
||||||
|
allCategories
|
||||||
|
.filter { it.isSelected }
|
||||||
|
.reduce { oldestSelected, category ->
|
||||||
|
when (oldestSelected.lastInteractedWithTimestamp <= category.lastInteractedWithTimestamp) {
|
||||||
|
true -> oldestSelected
|
||||||
|
false -> category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
oldestCategoryToDeselect?.let {
|
||||||
|
homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(it.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally update the selection.
|
||||||
|
homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categoryClicked.name))
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for all possible user interactions with the Pocket recommended stories feature.
|
||||||
|
*/
|
||||||
|
interface PocketStoriesInteractor {
|
||||||
|
/**
|
||||||
|
* Callback for when the user clicked a specific category.
|
||||||
|
*
|
||||||
|
* @param categoryClicked the just clicked [PocketRecommendedStoryCategory].
|
||||||
|
*/
|
||||||
|
fun onCategoryClick(categoryClicked: PocketRecommendedStoryCategory)
|
||||||
|
}
|
@ -0,0 +1,300 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.ext
|
||||||
|
|
||||||
|
import mozilla.components.service.pocket.PocketRecommendedStory
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentState
|
||||||
|
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
||||||
|
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class HomeFragmentStateTest {
|
||||||
|
private val otherStoriesCategory =
|
||||||
|
PocketRecommendedStoryCategory("other", getFakePocketStories(3, "other"))
|
||||||
|
private val anotherStoriesCategory =
|
||||||
|
PocketRecommendedStoryCategory("another", getFakePocketStories(3, "another"))
|
||||||
|
private val defaultStoriesCategory = PocketRecommendedStoryCategory(
|
||||||
|
POCKET_STORIES_DEFAULT_CATEGORY_NAME,
|
||||||
|
getFakePocketStories(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no category is selected WHEN getFilteredStories is called THEN only Pocket stories from the default category are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var result = homeState.getFilteredStories(2)
|
||||||
|
assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME })
|
||||||
|
|
||||||
|
result = homeState.getFilteredStories(5)
|
||||||
|
assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no category is selected WHEN getFilteredStories is called THEN no more than the indicated number of stories are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Asking for fewer than available
|
||||||
|
var result = homeState.getFilteredStories(2)
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
|
||||||
|
// Asking for more than available
|
||||||
|
result = homeState.getFilteredStories(5)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a category is selected WHEN getFilteredStories is called for fewer than in the category THEN only stories from that category are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var result = homeState.getFilteredStories(2)
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertNull(result.firstOrNull { it.category != otherStoriesCategory.name })
|
||||||
|
|
||||||
|
result = homeState.getFilteredStories(3)
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertNull(result.firstOrNull { it.category != otherStoriesCategory.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a category is selected WHEN getFilteredStories is called for more than in the category THEN results topped with ones from the default category are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = homeState.getFilteredStories(5)
|
||||||
|
|
||||||
|
assertEquals(5, result.size)
|
||||||
|
assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size)
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStories is called for fewer than in both THEN only stories from those categories are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory.copy(isSelected = true),
|
||||||
|
anotherStoriesCategory.copy(isSelected = true),
|
||||||
|
defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var result = homeState.getFilteredStories(2)
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
assertNull(
|
||||||
|
result.firstOrNull {
|
||||||
|
it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = homeState.getFilteredStories(6)
|
||||||
|
assertEquals(6, result.size)
|
||||||
|
assertNull(
|
||||||
|
result.firstOrNull {
|
||||||
|
it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStories is called for more than in the categories THEN results topped with ones from the default category are returned`() {
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
otherStoriesCategory.copy(isSelected = true),
|
||||||
|
anotherStoriesCategory.copy(isSelected = true),
|
||||||
|
defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = homeState.getFilteredStories(8)
|
||||||
|
|
||||||
|
assertEquals(8, result.size)
|
||||||
|
assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size)
|
||||||
|
assertEquals(3, result.filter { it.category == anotherStoriesCategory.name }.size)
|
||||||
|
assertEquals(
|
||||||
|
2,
|
||||||
|
result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStories is called for an odd number of stories THEN there are more by one stories from the oldest category`() {
|
||||||
|
val firstSelectedCategory = otherStoriesCategory.copy(lastInteractedWithTimestamp = 0, isSelected = true)
|
||||||
|
val lastSelectedCategory = anotherStoriesCategory.copy(lastInteractedWithTimestamp = 1, isSelected = true)
|
||||||
|
val homeState = HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
firstSelectedCategory, lastSelectedCategory, defaultStoriesCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = homeState.getFilteredStories(5)
|
||||||
|
|
||||||
|
assertEquals(5, result.size)
|
||||||
|
assertEquals(3, result.filter { it.category == firstSelectedCategory.name }.size)
|
||||||
|
assertEquals(2, result.filter { it.category == lastSelectedCategory.name }.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN no category is selected WHEN getFilteredStoriesCount is called THEN Pocket stories count only from the default category are returned`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
var result = getFilteredStoriesCount(availableCategories, emptyList(), 2)
|
||||||
|
assertEquals(1, result.keys.size)
|
||||||
|
assertEquals(defaultStoriesCategory.name, result.entries.first().key)
|
||||||
|
assertEquals(2, result[defaultStoriesCategory.name])
|
||||||
|
|
||||||
|
result = getFilteredStoriesCount(availableCategories, emptyList(), 5)
|
||||||
|
assertEquals(1, result.keys.size)
|
||||||
|
assertEquals(defaultStoriesCategory.name, result.entries.first().key)
|
||||||
|
assertEquals(3, result[defaultStoriesCategory.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for at most the stories from this category THEN only stories count only from that category are returned`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 2)
|
||||||
|
assertEquals(1, result.keys.size)
|
||||||
|
assertEquals(otherStoriesCategory.name, result.entries.first().key)
|
||||||
|
assertEquals(2, result[otherStoriesCategory.name])
|
||||||
|
|
||||||
|
result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 3)
|
||||||
|
assertEquals(1, result.keys.size)
|
||||||
|
assertEquals(otherStoriesCategory.name, result.entries.first().key)
|
||||||
|
assertEquals(3, result[otherStoriesCategory.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for more stories than this category has THEN results topped with ones from the default category are returned`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 5)
|
||||||
|
|
||||||
|
assertEquals(2, result.keys.size)
|
||||||
|
assertTrue(
|
||||||
|
result.keys.containsAll(
|
||||||
|
listOf(
|
||||||
|
defaultStoriesCategory.name,
|
||||||
|
otherStoriesCategory.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(3, result[otherStoriesCategory.name])
|
||||||
|
assertEquals(2, result[defaultStoriesCategory.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for at most the stories count in both THEN only stories counts from those categories are returned`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 2)
|
||||||
|
assertEquals(2, result.keys.size)
|
||||||
|
assertTrue(
|
||||||
|
result.keys.containsAll(
|
||||||
|
listOf(
|
||||||
|
otherStoriesCategory.name,
|
||||||
|
anotherStoriesCategory.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(1, result[otherStoriesCategory.name])
|
||||||
|
assertEquals(1, result[anotherStoriesCategory.name])
|
||||||
|
|
||||||
|
result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 6)
|
||||||
|
assertEquals(2, result.keys.size)
|
||||||
|
assertTrue(
|
||||||
|
result.keys.containsAll(
|
||||||
|
listOf(
|
||||||
|
otherStoriesCategory.name,
|
||||||
|
anotherStoriesCategory.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(3, result[otherStoriesCategory.name])
|
||||||
|
assertEquals(3, result[anotherStoriesCategory.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for more results than in those categories THEN results topped with ones from the default category are returned`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 8)
|
||||||
|
|
||||||
|
assertEquals(3, result.size)
|
||||||
|
assertTrue(
|
||||||
|
result.keys.containsAll(
|
||||||
|
listOf(
|
||||||
|
defaultStoriesCategory.name,
|
||||||
|
otherStoriesCategory.name,
|
||||||
|
anotherStoriesCategory.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(3, result[otherStoriesCategory.name])
|
||||||
|
assertEquals(3, result[anotherStoriesCategory.name])
|
||||||
|
assertEquals(2, result[defaultStoriesCategory.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for an odd number of results THEN there are more by one results from first selected category`() {
|
||||||
|
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
|
||||||
|
|
||||||
|
// The lastInteractedWithTimestamp is not checked in this method but the selected categories order
|
||||||
|
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 5)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
result.keys.containsAll(
|
||||||
|
listOf(
|
||||||
|
otherStoriesCategory.name,
|
||||||
|
anotherStoriesCategory.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(3, result[otherStoriesCategory.name])
|
||||||
|
assertEquals(2, result[anotherStoriesCategory.name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFakePocketStories(
|
||||||
|
limit: Int = 1,
|
||||||
|
category: String = POCKET_STORIES_DEFAULT_CATEGORY_NAME
|
||||||
|
): List<PocketRecommendedStory> {
|
||||||
|
return mutableListOf<PocketRecommendedStory>().apply {
|
||||||
|
for (index in 0 until limit) {
|
||||||
|
val randomNumber = Random.nextInt(0, 10)
|
||||||
|
|
||||||
|
add(
|
||||||
|
PocketRecommendedStory(
|
||||||
|
title = "This is a ${"very ".repeat(randomNumber)} long title",
|
||||||
|
publisher = "Publisher",
|
||||||
|
url = "https://story$randomNumber.com",
|
||||||
|
imageUrl = "",
|
||||||
|
timeToRead = randomNumber,
|
||||||
|
category = category
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
|
||||||
|
|
||||||
|
import io.mockk.spyk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.junit.Test
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentAction
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentState
|
||||||
|
import org.mozilla.fenix.home.HomeFragmentStore
|
||||||
|
|
||||||
|
class DefaultPocketStoriesControllerTest {
|
||||||
|
@Test
|
||||||
|
fun `GIVEN a category is selected WHEN that same category is clicked THEN deselect it`() {
|
||||||
|
val category1 = PocketRecommendedStoryCategory("cat1", emptyList(), isSelected = false)
|
||||||
|
val category2 = PocketRecommendedStoryCategory("cat2", emptyList(), isSelected = true)
|
||||||
|
val store = spyk(
|
||||||
|
HomeFragmentStore(
|
||||||
|
HomeFragmentState(pocketStoriesCategories = listOf(category1, category2))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val controller = DefaultPocketStoriesController(store)
|
||||||
|
|
||||||
|
controller.handleCategoryClick(category1)
|
||||||
|
verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category1.name)) }
|
||||||
|
|
||||||
|
controller.handleCategoryClick(category2)
|
||||||
|
verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category2.name)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN 7 categories are selected WHEN when a new one is clicked THEN the oldest seleected is deselected before selecting the new one`() {
|
||||||
|
val category1 = PocketRecommendedStoryCategory(
|
||||||
|
"cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111
|
||||||
|
)
|
||||||
|
val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222)
|
||||||
|
val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333)
|
||||||
|
val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0)
|
||||||
|
val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444)
|
||||||
|
val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555)
|
||||||
|
val category6 = category1.copy("cat6", lastInteractedWithTimestamp = 678)
|
||||||
|
val newSelectedCategory = category1.copy(
|
||||||
|
"newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321
|
||||||
|
)
|
||||||
|
val store = spyk(
|
||||||
|
HomeFragmentStore(
|
||||||
|
HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
category1, category2, category3, category4, category5, category6, oldestSelectedCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val controller = DefaultPocketStoriesController(store)
|
||||||
|
|
||||||
|
controller.handleCategoryClick(newSelectedCategory)
|
||||||
|
|
||||||
|
verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
|
||||||
|
verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GIVEN fewer than 7 categories are selected WHEN when a new one is clicked THEN don't deselect anything but select the newly clicked category`() {
|
||||||
|
val category1 = PocketRecommendedStoryCategory(
|
||||||
|
"cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111
|
||||||
|
)
|
||||||
|
val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222)
|
||||||
|
val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333)
|
||||||
|
val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0)
|
||||||
|
val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444)
|
||||||
|
val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555)
|
||||||
|
val newSelectedCategory = category1.copy(
|
||||||
|
"newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321
|
||||||
|
)
|
||||||
|
val store = spyk(
|
||||||
|
HomeFragmentStore(
|
||||||
|
HomeFragmentState(
|
||||||
|
pocketStoriesCategories = listOf(
|
||||||
|
category1, category2, category3, category4, category5, oldestSelectedCategory
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val controller = DefaultPocketStoriesController(store)
|
||||||
|
|
||||||
|
controller.handleCategoryClick(newSelectedCategory)
|
||||||
|
|
||||||
|
verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
|
||||||
|
verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) }
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue