Bug 1857215 - Part 2:Integrate review checker recommendations

fenix/121.0
rahulsainani 8 months ago committed by mergify[bot]
parent da457de30d
commit cec1940d38

@ -0,0 +1,50 @@
/* 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.shopping.middleware
import mozilla.components.concept.engine.shopping.ProductRecommendation
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale
private const val MINIMUM_FRACTION_DIGITS = 0
private const val MAXIMUM_FRACTION_DIGITS = 2
/**
* Maps [ProductRecommendation] to [RecommendedProductState].
*/
fun ProductRecommendation?.toRecommendedProductState(): RecommendedProductState =
this?.toRecommendedProduct() ?: RecommendedProductState.Error
private fun ProductRecommendation.toRecommendedProduct(): RecommendedProductState.Product =
RecommendedProductState.Product(
aid = aid,
name = name,
productUrl = url,
imageUrl = imageUrl,
formattedPrice = price.toDouble().toFormattedAmount(currency),
reviewGrade = grade.asEnumOrDefault<ReviewQualityCheckState.Grade>()!!,
adjustedRating = adjustedRating.toFloat(),
isSponsored = sponsored,
analysisUrl = analysisUrl,
)
private fun Double.toFormattedAmount(currencyCode: String): String =
mapCurrencyCodeToNumberFormat(currencyCode).apply {
minimumFractionDigits = MINIMUM_FRACTION_DIGITS
maximumFractionDigits = MAXIMUM_FRACTION_DIGITS
}.format(this)
private fun mapCurrencyCodeToNumberFormat(currencyCode: String): NumberFormat =
try {
val currency = Currency.getInstance(currencyCode)
NumberFormat.getCurrencyInstance(Locale.getDefault()).apply {
this.currency = currency
}
} catch (e: IllegalArgumentException) {
NumberFormat.getNumberInstance()
}

@ -14,10 +14,12 @@ import org.mozilla.fenix.components.appstate.AppAction.ShoppingAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.FetchProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.RetryProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckAction.UpdateRecommendedProduct
import org.mozilla.fenix.shopping.store.ReviewQualityCheckMiddleware
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
/**
* Middleware that handles network requests for the review quality check feature.
@ -72,6 +74,10 @@ class ReviewQualityCheckNetworkMiddleware(
productAnalysis = productAnalysis,
)
}
if (productReviewState is ProductReviewState.AnalysisPresent) {
store.updateRecommendedProductState()
}
}
ReviewQualityCheckAction.ReanalyzeProduct, ReviewQualityCheckAction.AnalyzeProduct -> {
@ -150,4 +156,16 @@ class ReviewQualityCheckNetworkMiddleware(
private fun ProductReviewState.isAnalysisPresentOrNoAnalysisPresent() =
this is ProductReviewState.AnalysisPresent || this is ProductReviewState.NoAnalysisPresent
private suspend fun Store<ReviewQualityCheckState, ReviewQualityCheckAction>.updateRecommendedProductState() {
val currentState = state
if (currentState is ReviewQualityCheckState.OptedIn &&
currentState.productRecommendationsPreference == true
) {
dispatch(UpdateRecommendedProduct(RecommendedProductState.Loading))
reviewQualityCheckService.productRecommendation().toRecommendedProductState().also {
dispatch(UpdateRecommendedProduct(it))
}
}
}
}

@ -6,6 +6,7 @@ package org.mozilla.fenix.shopping.store
import mozilla.components.lib.state.Action
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
/**
* Actions for review quality check feature.
@ -79,10 +80,17 @@ sealed interface ReviewQualityCheckAction : Action {
) : UpdateAction, TelemetryAction
/**
* Triggered as a result of a [NetworkAction] to update the state.
* Triggered as a result of a [NetworkAction] to update the [ProductReviewState].
*/
data class UpdateProductReview(val productReviewState: ProductReviewState) : UpdateAction
/**
* Triggered as a result of a [NetworkAction] to update the [RecommendedProductState].
*/
data class UpdateRecommendedProduct(
val recommendedProductState: RecommendedProductState,
) : UpdateAction
/**
* Triggered when the user has opted in to the review quality check feature and the UI is opened.
*/

@ -171,6 +171,7 @@ sealed interface ReviewQualityCheckState : State {
/**
* The state when the recommended product is available.
*
* @property aid The unique identifier of the product.
* @property name The name of the product.
* @property productUrl The url of the product.
* @property imageUrl The url of the image of the product.
@ -181,6 +182,7 @@ sealed interface ReviewQualityCheckState : State {
* @property analysisUrl The url of the analysis of the product.
*/
data class Product(
val aid: String,
val name: String,
val productUrl: String,
val imageUrl: String,

@ -36,6 +36,7 @@ private fun reducer(
return state
}
@Suppress("LongMethod")
private fun mapStateForUpdateAction(
state: ReviewQualityCheckState,
action: ReviewQualityCheckAction.UpdateAction,
@ -51,6 +52,7 @@ private fun mapStateForUpdateAction(
)
}
}
is ReviewQualityCheckAction.OptOutCompleted -> {
ReviewQualityCheckState.NotOptedIn(action.productVendors)
}
@ -61,7 +63,19 @@ private fun mapStateForUpdateAction(
ReviewQualityCheckAction.ToggleProductRecommendation -> {
if (state is ReviewQualityCheckState.OptedIn && state.productRecommendationsPreference != null) {
state.copy(productRecommendationsPreference = !state.productRecommendationsPreference)
if (state.productReviewState is ProductReviewState.AnalysisPresent &&
state.productRecommendationsPreference
) {
// Removes any existing product recommendation from UI
state.copy(
productRecommendationsPreference = false,
productReviewState = state.productReviewState.copy(
recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Initial,
),
)
} else {
state.copy(productRecommendationsPreference = !state.productRecommendationsPreference)
}
} else {
state
}
@ -98,5 +112,21 @@ private fun mapStateForUpdateAction(
}
}
}
is ReviewQualityCheckAction.UpdateRecommendedProduct -> {
state.mapIfOptedIn {
if (it.productReviewState is ProductReviewState.AnalysisPresent &&
it.productRecommendationsPreference == true
) {
it.copy(
productReviewState = it.productReviewState.copy(
recommendedProductState = action.recommendedProductState,
),
)
} else {
it
}
}
}
}
}

@ -564,6 +564,7 @@ private class ProductAnalysisPreviewModelParameterProvider :
ProductAnalysisPreviewModel(
productRecommendationsEnabled = true,
recommendedProductState = RecommendedProductState.Product(
aid = "aid",
name = "The best desk ever with a really really really long product name that " +
"forces the preview to wrap its text to at least 4 lines.",
productUrl = "www.mozilla.com",

@ -0,0 +1,37 @@
/* 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.helpers
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.util.Locale
/**
* A JUnit [TestRule] that sets the default locale to a given [localeToSet] for the duration of
* the test and then resets it to the original locale.
*
* @param localeToSet The locale to set for the duration of the test.
*/
class LocaleTestRule(private val localeToSet: Locale) : TestRule {
private var originalLocale: Locale? = null
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
originalLocale = Locale.getDefault()
Locale.setDefault(localeToSet)
try {
base.evaluate() // Run the tests
} finally {
// Reset to the original locale after tests
Locale.setDefault(originalLocale!!)
}
}
}
}

@ -8,6 +8,7 @@ import mozilla.components.concept.engine.shopping.Highlight
import mozilla.components.concept.engine.shopping.ProductAnalysis
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.RecommendedProductState
import java.util.SortedMap
object ProductAnalysisTestData {
@ -43,6 +44,7 @@ object ProductAnalysisTestData {
adjustedRating: Float? = 4.5f,
analysisStatus: AnalysisStatus = AnalysisStatus.UP_TO_DATE,
highlights: SortedMap<ReviewQualityCheckState.HighlightType, List<String>>? = null,
recommendedProductState: RecommendedProductState = RecommendedProductState.Initial,
): ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent =
ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent(
productId = productId,
@ -51,5 +53,6 @@ object ProductAnalysisTestData {
adjustedRating = adjustedRating,
analysisStatus = analysisStatus,
highlights = highlights,
recommendedProductState = recommendedProductState,
)
}

@ -0,0 +1,58 @@
/* 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.shopping
import mozilla.components.concept.engine.shopping.ProductRecommendation
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
object ProductRecommendationTestData {
fun productRecommendation(
aid: String = "aid",
url: String = "https://test.com",
grade: String = "A",
adjustedRating: Double = 4.7,
sponsored: Boolean = true,
analysisUrl: String = "analysisUrl",
imageUrl: String = "https://imageurl.com",
name: String = "Test Product",
price: String = "100",
currency: String = "USD",
): ProductRecommendation = ProductRecommendation(
aid = aid,
url = url,
grade = grade,
adjustedRating = adjustedRating,
sponsored = sponsored,
analysisUrl = analysisUrl,
imageUrl = imageUrl,
name = name,
price = price,
currency = currency,
)
fun product(
aid: String = "aid",
url: String = "https://test.com",
reviewGrade: ReviewQualityCheckState.Grade = ReviewQualityCheckState.Grade.A,
adjustedRating: Double = 4.7,
sponsored: Boolean = true,
analysisUrl: String = "analysisUrl",
imageUrl: String = "https://imageurl.com",
name: String = "Test Product",
formattedPrice: String = "$100",
): ReviewQualityCheckState.RecommendedProductState.Product =
ReviewQualityCheckState.RecommendedProductState.Product(
aid = aid,
productUrl = url,
reviewGrade = reviewGrade,
adjustedRating = adjustedRating.toFloat(),
isSponsored = sponsored,
analysisUrl = analysisUrl,
imageUrl = imageUrl,
name = name,
formattedPrice = formattedPrice,
)
}

@ -0,0 +1,50 @@
/* 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.shopping.middleware
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.helpers.LocaleTestRule
import org.mozilla.fenix.shopping.ProductRecommendationTestData
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState
import java.util.Locale
class ProductRecommendationMapperTest {
@get:Rule
val localeTestRule = LocaleTestRule(Locale.US)
@Test
fun `WHEN ProductRecommendation is null THEN it is mapped to Error`() {
val actual = null.toRecommendedProductState()
val expected = ReviewQualityCheckState.RecommendedProductState.Error
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductRecommendation has data THEN it is mapped to product`() {
val productRecommendation = ProductRecommendationTestData.productRecommendation()
val actual = productRecommendation.toRecommendedProductState()
val expected = ProductRecommendationTestData.product()
assertEquals(expected, actual)
}
@Test
fun `WHEN ProductRecommendation has data with invalid currency code THEN it is mapped to product`() {
val productRecommendation = ProductRecommendationTestData.productRecommendation(
price = "100",
currency = "invalid",
)
val actual = productRecommendation.toRecommendedProductState()
val expected = ProductRecommendationTestData.product(
formattedPrice = "100",
)
assertEquals(expected, actual)
}
}

@ -20,6 +20,7 @@ import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.shopping.ShoppingState
import org.mozilla.fenix.shopping.ProductAnalysisTestData
import org.mozilla.fenix.shopping.ProductRecommendationTestData
import org.mozilla.fenix.shopping.fake.FakeNetworkChecker
import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckPreferences
import org.mozilla.fenix.shopping.fake.FakeReviewQualityCheckService
@ -32,6 +33,7 @@ import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckPreferencesMiddle
import org.mozilla.fenix.shopping.middleware.ReviewQualityCheckService
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.OptedIn.ProductReviewState.AnalysisPresent.AnalysisStatus
import org.mozilla.fenix.shopping.store.ReviewQualityCheckState.ProductVendor
import java.util.Locale
class ReviewQualityCheckStoreTest {
@ -180,10 +182,17 @@ class ReviewQualityCheckStoreTest {
isEnabled = true,
isProductRecommendationsEnabled = true,
),
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { ProductAnalysisTestData.productAnalysis() },
productRecommendation = ProductRecommendationTestData.productRecommendation(),
),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.dispatch(ReviewQualityCheckAction.ToggleProductRecommendation).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
@ -191,6 +200,9 @@ class ReviewQualityCheckStoreTest {
val expected = ReviewQualityCheckState.OptedIn(
productRecommendationsPreference = false,
productVendor = ProductVendor.BEST_BUY,
productReviewState = ProductAnalysisTestData.analysisPresent(
recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Initial,
),
)
assertEquals(expected, tested.state)
}
@ -539,6 +551,98 @@ class ReviewQualityCheckStoreTest {
}
}
@Test
fun `GIVEN product recommendations are enabled WHEN a product analysis is fetched successfully THEN product recommendation should also be fetched and displayed if available`() =
runTest {
setAndResetLocale {
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
isEnabled = true,
isProductRecommendationsEnabled = true,
),
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { ProductAnalysisTestData.productAnalysis() },
productRecommendation = ProductRecommendationTestData.productRecommendation(),
),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ProductAnalysisTestData.analysisPresent(
recommendedProductState = ProductRecommendationTestData.product(),
),
productRecommendationsPreference = true,
productVendor = ProductVendor.BEST_BUY,
)
assertEquals(expected, tested.state)
}
}
@Test
fun `GIVEN product recommendations are enabled WHEN a product analysis is fetched successfully and product recommendation fails THEN product recommendations state should be error`() =
runTest {
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
isEnabled = true,
isProductRecommendationsEnabled = true,
),
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { ProductAnalysisTestData.productAnalysis() },
productRecommendation = null,
),
),
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
val expected = ReviewQualityCheckState.OptedIn(
productReviewState = ProductAnalysisTestData.analysisPresent(
recommendedProductState = ReviewQualityCheckState.RecommendedProductState.Error,
),
productRecommendationsPreference = true,
productVendor = ProductVendor.BEST_BUY,
)
assertEquals(expected, tested.state)
}
@Test
fun `GIVEN product recommendations are enabled WHEN product analysis fails THEN product recommendations should not be fetched`() =
runTest {
val captureActionsMiddleware = CaptureActionsMiddleware<ReviewQualityCheckState, ReviewQualityCheckAction>()
val tested = ReviewQualityCheckStore(
middleware = provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(
isEnabled = true,
isProductRecommendationsEnabled = true,
),
reviewQualityCheckService = FakeReviewQualityCheckService(
productAnalysis = { null },
productRecommendation = ProductRecommendationTestData.productRecommendation(),
),
) + captureActionsMiddleware,
)
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
tested.waitUntilIdle()
tested.dispatch(ReviewQualityCheckAction.FetchProductAnalysis).joinBlocking()
tested.waitUntilIdle()
dispatcher.scheduler.advanceUntilIdle()
captureActionsMiddleware.assertNotDispatched(ReviewQualityCheckAction.UpdateRecommendedProduct::class)
}
private fun provideReviewQualityCheckMiddleware(
reviewQualityCheckPreferences: ReviewQualityCheckPreferences = FakeReviewQualityCheckPreferences(),
reviewQualityCheckVendorsService: FakeReviewQualityCheckVendorsService = FakeReviewQualityCheckVendorsService(),
@ -560,4 +664,11 @@ class ReviewQualityCheckStoreTest {
),
)
}
private fun setAndResetLocale(locale: Locale = Locale.US, block: () -> Unit) {
val initialLocale = Locale.getDefault()
Locale.setDefault(locale)
block()
Locale.setDefault(initialLocale)
}
}

Loading…
Cancel
Save