From 178b07a64dfe071ed47168ebdf1ebed36001fba4 Mon Sep 17 00:00:00 2001 From: JayShortway <29483617+JayShortway@users.noreply.github.com> Date: Mon, 30 Dec 2024 12:47:45 +0100 Subject: [PATCH] [Paywalls V2] Moves validation logic to the Loading phase (#2007) --- .../components/LoadedPaywallComponents.kt | 427 +++++++++--------- .../components/button/ButtonComponentView.kt | 5 +- .../components/stack/StackComponentView.kt | 5 +- .../components/text/TextComponentView.kt | 5 +- .../ui/revenuecatui/data/PaywallState.kt | 17 +- .../ui/revenuecatui/data/PaywallViewModel.kt | 15 +- .../errors/PaywallValidationError.kt | 7 + .../ui/revenuecatui/helpers/NonEmptyMap.kt | 2 + .../ui/revenuecatui/helpers/NonEmptySet.kt | 43 ++ .../helpers/OfferingToStateMapper.kt | 112 ++++- .../helpers/PaywallValidationResult.kt | 35 +- .../ui/revenuecatui/helpers/Result.kt | 17 + .../strings/PaywallValidationErrorStrings.kt | 2 + .../LegacyPaywallDataValidationTest.kt | 44 +- .../PaywallComponentDataValidationTests.kt | 217 +++++++++ .../LoadedPaywallComponentsLocaleTests.kt | 12 +- .../components/text/TextComponentViewTests.kt | 31 +- .../text/TextComponentViewWindowTests.kt | 6 +- .../revenuecatui/helpers/FakePaywallState.kt | 4 +- .../ui/revenuecatui/helpers/ResultTests.kt | 65 ++- 20 files changed, 780 insertions(+), 291 deletions(-) create mode 100644 ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptySet.kt create mode 100644 ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt index 59e8d10139..24c3d5ea02 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponents.kt @@ -8,15 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview -import androidx.window.core.layout.WindowSizeClass import com.revenuecat.purchases.Offering import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.StickyFooterComponent @@ -49,11 +46,10 @@ import com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAli import com.revenuecat.purchases.paywalls.components.properties.TwoDimensionalAlignment.BOTTOM import com.revenuecat.purchases.ui.revenuecatui.components.modifier.background import com.revenuecat.purchases.ui.revenuecatui.components.properties.toBackgroundStyle -import com.revenuecat.purchases.ui.revenuecatui.components.stickyfooter.StickyFooterComponentView -import com.revenuecat.purchases.ui.revenuecatui.components.style.StyleFactory import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow -import com.revenuecat.purchases.ui.revenuecatui.helpers.toNonEmptyMapOrNull +import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.validate import java.net.URL @Composable @@ -64,24 +60,9 @@ internal fun LoadedPaywallComponents( val configuration = LocalConfiguration.current state.update(localeList = configuration.locales) - val windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val windowSize = ScreenCondition.from(windowSizeClass.windowWidthSizeClass) - - val styleFactory = remember(state.locale, windowSize) { - // TODO Remove the need for double bangs in the validation step. - val localizations = state.data.componentsLocalizations.mapValues { (_, localizationDictionary) -> - localizationDictionary.toNonEmptyMapOrNull()!! - }.toNonEmptyMapOrNull()!! - StyleFactory(localizations) - } - - val config = state.data.componentsConfig.base - val actionHandler: suspend (PaywallAction) -> Unit = { /* TODO Implement action handler */ } - val style = styleFactory.create(config.stack, actionHandler).getOrThrow() - val footerComponentStyle = config.stickyFooter?.let { - styleFactory.createStickyFooterComponentStyle(it, actionHandler).getOrThrow() - } - val background = config.background.toBackgroundStyle() + val style = state.stack + val footerComponentStyle = state.stickyFooter + val background = state.background.toBackgroundStyle() Column(modifier = modifier.background(background)) { ComponentView( @@ -93,7 +74,7 @@ internal fun LoadedPaywallComponents( .verticalScroll(rememberScrollState()), ) footerComponentStyle?.let { - StickyFooterComponentView( + ComponentView( style = it, state = state, modifier = Modifier @@ -108,76 +89,76 @@ internal fun LoadedPaywallComponents( @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun LoadedPaywallComponents_Preview() { - LoadedPaywallComponents( - state = PaywallState.Loaded.Components( - offering = Offering( - identifier = "id", - serverDescription = "description", - metadata = emptyMap(), - availablePackages = emptyList(), - paywall = null, - ), - data = PaywallComponentsData( - templateName = "template", - assetBaseURL = URL("https://assets.pawwalls.com"), - componentsConfig = ComponentsConfig( - base = PaywallComponentsConfig( - stack = StackComponent( - components = listOf( - TextComponent( - text = LocalizationKey("hello-world"), - color = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())), - ), + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("hello-world"), + color = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())), + ), + ), + dimension = Vertical(alignment = CENTER, distribution = START), + backgroundColor = ColorScheme(light = ColorInfo.Hex(Color.Cyan.toArgb())), + ), + background = Background.Color( + ColorScheme( + light = ColorInfo.Hex(Color.Blue.toArgb()), + dark = ColorInfo.Hex(Color.Red.toArgb()), + ), + ), + stickyFooter = StickyFooterComponent( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("sticky-footer"), + color = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())), ), - dimension = Vertical(alignment = CENTER, distribution = START), - backgroundColor = ColorScheme(light = ColorInfo.Hex(Color.Cyan.toArgb())), ), - background = Background.Color( - ColorScheme( - light = ColorInfo.Hex(Color.Blue.toArgb()), - dark = ColorInfo.Hex(Color.Red.toArgb()), + dimension = Vertical(alignment = CENTER, distribution = START), + backgroundColor = ColorScheme(light = ColorInfo.Hex(Color.White.toArgb())), + shape = Shape.Rectangle( + corners = CornerRadiuses( + topLeading = 10.0, + topTrailing = 10.0, + bottomLeading = 0.0, + bottomTrailing = 0.0, ), ), - stickyFooter = StickyFooterComponent( - stack = StackComponent( - components = listOf( - TextComponent( - text = LocalizationKey("sticky-footer"), - color = ColorScheme(light = ColorInfo.Hex(Color.Black.toArgb())), - ), - ), - dimension = Vertical(alignment = CENTER, distribution = START), - backgroundColor = ColorScheme(light = ColorInfo.Hex(Color.White.toArgb())), - shape = Shape.Rectangle( - corners = CornerRadiuses( - topLeading = 10.0, - topTrailing = 10.0, - bottomLeading = 0.0, - bottomTrailing = 0.0, - ), - ), - shadow = Shadow( - ColorScheme( - light = ColorInfo.Hex(Color.Black.toArgb()), - dark = ColorInfo.Hex(Color.Yellow.toArgb()), - ), - radius = 10.0, - x = 0.0, - y = -5.0, - ), + shadow = Shadow( + ColorScheme( + light = ColorInfo.Hex(Color.Black.toArgb()), + dark = ColorInfo.Hex(Color.Yellow.toArgb()), ), + radius = 10.0, + x = 0.0, + y = -5.0, ), ), ), - componentsLocalizations = mapOf( - LocaleId("en_US") to mapOf( - LocalizationKey("hello-world") to LocalizationData.Text("Hello, world!"), - LocalizationKey("sticky-footer") to LocalizationData.Text("Sticky Footer"), - ), - ), - defaultLocaleIdentifier = LocaleId("en_US"), ), ), + componentsLocalizations = mapOf( + LocaleId("en_US") to mapOf( + LocalizationKey("hello-world") to LocalizationData.Text("Hello, world!"), + LocalizationKey("sticky-footer") to LocalizationData.Text("Sticky Footer"), + ), + ), + defaultLocaleIdentifier = LocaleId("en_US"), + ) + val offering = Offering( + identifier = "id", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + val state = offering.toComponentsPaywallState(data.validate().getOrThrow()) + LoadedPaywallComponents( + state = state, modifier = Modifier .fillMaxSize(), ) @@ -195,162 +176,162 @@ private fun LoadedPaywallComponents_Preview_Bless() { light = ColorInfo.Hex(Color.White.toArgb()), dark = ColorInfo.Hex(Color.Black.toArgb()), ) - - LoadedPaywallComponents( - state = PaywallState.Loaded.Components( - offering = Offering( - identifier = "id", - serverDescription = "description", - metadata = emptyMap(), - availablePackages = emptyList(), - paywall = null, - ), - data = PaywallComponentsData( - templateName = "template", - assetBaseURL = URL("https://assets.pawwalls.com"), - componentsConfig = ComponentsConfig( - base = PaywallComponentsConfig( - stack = StackComponent( - components = listOf( - StackComponent( - components = emptyList(), - dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), - size = Size(width = Fill, height = Fill), - backgroundColor = ColorScheme( - light = ColorInfo.Gradient.Linear( - degrees = 60f, - points = listOf( - ColorInfo.Gradient.Point( - color = Color(red = 0xFF, green = 0xFF, blue = 0xFF, alpha = 0xFF) - .toArgb(), - percent = 0.4f, - ), - ColorInfo.Gradient.Point( - color = Color(red = 5, green = 124, blue = 91).toArgb(), - percent = 1f, - ), - ), + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + StackComponent( + components = emptyList(), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fill, height = Fill), + backgroundColor = ColorScheme( + light = ColorInfo.Gradient.Linear( + degrees = 60f, + points = listOf( + ColorInfo.Gradient.Point( + color = Color(red = 0xFF, green = 0xFF, blue = 0xFF, alpha = 0xFF) + .toArgb(), + percent = 0.4f, + ), + ColorInfo.Gradient.Point( + color = Color(red = 5, green = 124, blue = 91).toArgb(), + percent = 1f, ), ), ), + ), + ), + StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("title"), + color = textColor, + fontWeight = FontWeight.SEMI_BOLD, + fontSize = FontSize.HEADING_L, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 0.0, bottom = 40.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-1"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-2"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-3"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-4"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-5"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("feature-6"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), + TextComponent( + text = LocalizationKey("offer"), + color = textColor, + horizontalAlignment = LEADING, + size = Size(width = Fill, height = Fit), + margin = Padding(top = 48.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + ), StackComponent( components = listOf( TextComponent( - text = LocalizationKey("title"), - color = textColor, - fontWeight = FontWeight.SEMI_BOLD, - fontSize = FontSize.HEADING_L, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 0.0, bottom = 40.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-1"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-2"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-3"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-4"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-5"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("feature-6"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - TextComponent( - text = LocalizationKey("offer"), - color = textColor, - horizontalAlignment = LEADING, - size = Size(width = Fill, height = Fit), - margin = Padding(top = 48.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - ), - StackComponent( - components = listOf( - TextComponent( - text = LocalizationKey("cta"), - color = ColorScheme( - light = ColorInfo.Hex(Color.White.toArgb()), - ), - fontWeight = FontWeight.BOLD, - ), - ), - dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), - size = Size(width = Fit, height = Fit), - backgroundColor = ColorScheme( - light = ColorInfo.Hex(Color(red = 5, green = 124, blue = 91).toArgb()), + text = LocalizationKey("cta"), + color = ColorScheme( + light = ColorInfo.Hex(Color.White.toArgb()), ), - padding = Padding(top = 8.0, bottom = 8.0, leading = 32.0, trailing = 32.0), - margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), - shape = Shape.Pill, - ), - TextComponent( - text = LocalizationKey("terms"), - color = textColor, + fontWeight = FontWeight.BOLD, ), ), - dimension = Vertical(alignment = LEADING, distribution = END), - size = Size(width = Fill, height = Fill), - padding = Padding(top = 16.0, bottom = 16.0, leading = 32.0, trailing = 32.0), + dimension = ZLayer(alignment = TwoDimensionalAlignment.CENTER), + size = Size(width = Fit, height = Fit), + backgroundColor = ColorScheme( + light = ColorInfo.Hex(Color(red = 5, green = 124, blue = 91).toArgb()), + ), + padding = Padding(top = 8.0, bottom = 8.0, leading = 32.0, trailing = 32.0), + margin = Padding(top = 8.0, bottom = 8.0, leading = 0.0, trailing = 0.0), + shape = Shape.Pill, + ), + TextComponent( + text = LocalizationKey("terms"), + color = textColor, ), ), - dimension = ZLayer(alignment = BOTTOM), + dimension = Vertical(alignment = LEADING, distribution = END), size = Size(width = Fill, height = Fill), - backgroundColor = backgroundColor, + padding = Padding(top = 16.0, bottom = 16.0, leading = 32.0, trailing = 32.0), ), - background = Background.Color(backgroundColor), - stickyFooter = null, ), + dimension = ZLayer(alignment = BOTTOM), + size = Size(width = Fill, height = Fill), + backgroundColor = backgroundColor, ), - componentsLocalizations = mapOf( - LocaleId("en_US") to mapOf( - LocalizationKey("title") to LocalizationData.Text("Unlock bless."), - LocalizationKey("feature-1") to LocalizationData.Text("✓ Enjoy a 7 day trial"), - LocalizationKey("feature-2") to LocalizationData.Text("✓ Change currencies"), - LocalizationKey("feature-3") to LocalizationData.Text("✓ Access more trend charts"), - LocalizationKey("feature-4") to LocalizationData.Text("✓ Create custom categories"), - LocalizationKey("feature-5") to LocalizationData.Text("✓ Get a special premium icon"), - LocalizationKey("feature-6") to LocalizationData.Text( - "✓ Receive our love and gratitude for your support", - ), - LocalizationKey("offer") to LocalizationData.Text( - "Try 7 days free, then $19.98/year. Cancel anytime.", - ), - LocalizationKey("cta") to LocalizationData.Text("Continue"), - LocalizationKey("terms") to LocalizationData.Text("Privacy & Terms"), - ), + background = Background.Color(backgroundColor), + stickyFooter = null, + ), + ), + componentsLocalizations = mapOf( + LocaleId("en_US") to mapOf( + LocalizationKey("title") to LocalizationData.Text("Unlock bless."), + LocalizationKey("feature-1") to LocalizationData.Text("✓ Enjoy a 7 day trial"), + LocalizationKey("feature-2") to LocalizationData.Text("✓ Change currencies"), + LocalizationKey("feature-3") to LocalizationData.Text("✓ Access more trend charts"), + LocalizationKey("feature-4") to LocalizationData.Text("✓ Create custom categories"), + LocalizationKey("feature-5") to LocalizationData.Text("✓ Get a special premium icon"), + LocalizationKey("feature-6") to LocalizationData.Text( + "✓ Receive our love and gratitude for your support", ), - defaultLocaleIdentifier = LocaleId("en_US"), + LocalizationKey("offer") to LocalizationData.Text( + "Try 7 days free, then $19.98/year. Cancel anytime.", + ), + LocalizationKey("cta") to LocalizationData.Text("Continue"), + LocalizationKey("terms") to LocalizationData.Text("Privacy & Terms"), ), ), + defaultLocaleIdentifier = LocaleId("en_US"), + ) + val offering = Offering( + identifier = "id", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + val state = offering.toComponentsPaywallState(data.validate().getOrThrow()) + + LoadedPaywallComponents( + state = state, modifier = Modifier .fillMaxSize(), ) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt index 775742750f..77cf318ab4 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt @@ -45,7 +45,10 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.ButtonComponent import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentStyle import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf +import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.validate import kotlinx.coroutines.launch import java.net.URL @@ -148,5 +151,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { paywallComponents = data, ) - return PaywallState.Loaded.Components(offering, data) + return offering.toComponentsPaywallState(data.validate().getOrThrow()) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt index 3f81c180dc..62f91daf3a 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt @@ -58,7 +58,10 @@ import com.revenuecat.purchases.ui.revenuecatui.components.style.StackComponentS import com.revenuecat.purchases.ui.revenuecatui.components.style.TextComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull +import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf +import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.validate import java.net.URL @Suppress("LongMethod") @@ -336,5 +339,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { paywallComponents = data, ) - return PaywallState.Loaded.Components(offering, data) + return offering.toComponentsPaywallState(data.validate().getOrThrow()) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt index a1eaf778f0..5b5c70e8ee 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentView.kt @@ -50,8 +50,11 @@ import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableDataProvider import com.revenuecat.purchases.ui.revenuecatui.data.processed.VariableProcessor import com.revenuecat.purchases.ui.revenuecatui.extensions.applyIfNotNull +import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow import com.revenuecat.purchases.ui.revenuecatui.helpers.nonEmptyMapOf +import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.toResourceProvider +import com.revenuecat.purchases.ui.revenuecatui.helpers.validate import java.net.URL @Composable @@ -414,5 +417,5 @@ private fun previewEmptyState(): PaywallState.Loaded.Components { paywallComponents = data, ) - return PaywallState.Loaded.Components(offering, data) + return offering.toComponentsPaywallState(data.validate().getOrThrow()) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt index 5851d5293e..3b07c8138d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallState.kt @@ -11,13 +11,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.intl.LocaleList import com.revenuecat.purchases.Offering import com.revenuecat.purchases.Package +import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.LocaleId -import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toComposeLocale import com.revenuecat.purchases.ui.revenuecatui.components.ktx.toLocaleId +import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.processed.ProcessedLocalizedConfiguration import com.revenuecat.purchases.ui.revenuecatui.data.processed.TemplateConfiguration import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger +import com.revenuecat.purchases.ui.revenuecatui.helpers.NonEmptySet import com.revenuecat.purchases.ui.revenuecatui.isFullScreen import android.os.LocaleList as FrameworkLocaleList @@ -58,10 +60,17 @@ internal sealed interface PaywallState { } } + @Suppress("LongParameterList") @Stable class Components( + val stack: ComponentStyle, + val stickyFooter: ComponentStyle?, + val background: Background, override val offering: Offering, - val data: PaywallComponentsData, + /** + * All locales that this paywall supports, with `locales.head` being the default one. + */ + private val locales: NonEmptySet, initialLocaleList: LocaleList = LocaleList.current, initialIsEligibleForIntroOffer: Boolean = false, initialSelectedPackage: Package? = null, @@ -90,9 +99,9 @@ internal sealed interface PaywallState { private fun LocaleList.toLocaleId(): LocaleId = // Configured locales take precedence over the default one. - map { it.toLocaleId() }.plus(data.defaultLocaleIdentifier) + map { it.toLocaleId() }.plus(locales.head) // Find the first locale we have a LocalizationDictionary for. - .first { id -> data.componentsLocalizations.containsKey(id) } + .first { id -> locales.contains(id) } private fun List.mostExpensivePricePerMonthMicros(): Long? = asSequence() diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index fb1eb43244..b334cc7106 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -31,7 +31,7 @@ import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult import com.revenuecat.purchases.ui.revenuecatui.helpers.ResourceProvider import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState import com.revenuecat.purchases.ui.revenuecatui.helpers.toLegacyPaywallState -import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedLegacyPaywall +import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedPaywall import com.revenuecat.purchases.ui.revenuecatui.strings.PaywallValidationErrorStrings import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -374,13 +374,12 @@ internal class PaywallViewModelImpl( return PaywallState.Error("No packages available") } - val validationResult = offering.paywallComponents - // TODO Actually validate the PaywallComponentsData - ?.let { PaywallValidationResult.Components(displayablePaywall = it) } - ?: offering.validatedLegacyPaywall(colorScheme, resourceProvider) + val validationResult = offering.validatedPaywall(colorScheme, resourceProvider) - validationResult.error?.let { validationError -> - Logger.w(validationError.associatedErrorString(offering)) + validationResult.errors?.let { validationErrors -> + validationErrors.forEach { error -> + Logger.w(error.associatedErrorString(offering)) + } Logger.w(PaywallValidationErrorStrings.DISPLAYING_DEFAULT) } @@ -398,7 +397,7 @@ internal class PaywallViewModelImpl( storefrontCountryCode = storefrontCountryCode, ) is PaywallValidationResult.Components -> offering.toComponentsPaywallState( - validatedPaywallData = validationResult.displayablePaywall, + validationResult = validationResult, ) } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt index 9e2c8a6c49..de91f46a83 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/errors/PaywallValidationError.kt @@ -28,6 +28,7 @@ internal sealed class PaywallValidationError : Throwable() { } is MissingStringLocalization -> message is MissingImageLocalization -> message + is AllLocalizationsMissing -> message } } @@ -53,4 +54,10 @@ internal sealed class PaywallValidationError : Throwable() { PaywallValidationErrorStrings.MISSING_IMAGE_LOCALIZATION_WITH_LOCALE.format(key.value, locale.value) } ?: PaywallValidationErrorStrings.MISSING_IMAGE_LOCALIZATION.format(key.value) } + data class AllLocalizationsMissing( + val locale: LocaleId, + ) : PaywallValidationError() { + override val message: String = + PaywallValidationErrorStrings.ALL_LOCALIZATIONS_MISSING_FOR_LOCALE.format(locale.value) + } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptyMap.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptyMap.kt index 5838798b0c..600526590c 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptyMap.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptyMap.kt @@ -13,6 +13,8 @@ internal class NonEmptyMap private constructor( constructor(entry: Pair, tail: Map) : this(entry = mapOf(entry).entries.first(), all = tail + entry) + override val keys: NonEmptySet = NonEmptySet(entry.key, all.keys) + @JvmSynthetic fun toMap(): Map = all diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptySet.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptySet.kt new file mode 100644 index 0000000000..906fc444c5 --- /dev/null +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/NonEmptySet.kt @@ -0,0 +1,43 @@ +@file:JvmSynthetic + +package com.revenuecat.purchases.ui.revenuecatui.helpers + +/** + * A Set that is guaranteed to have at least 1 element. Inspired by Arrow. Use [nonEmptySetOf] or + * [toNonEmptySetOrNull] to construct. + */ +internal class NonEmptySet private constructor( + @get:JvmSynthetic + val head: A, + private val all: Set, +) : Set by all { + + constructor(head: A, rest: Iterable) : this(head, all = rest.toSet() + head) + + @JvmSynthetic + fun toSet(): Set = all + + @JvmSynthetic + override fun isEmpty(): Boolean = false + + override fun equals(other: Any?): Boolean = when (other) { + is NonEmptySet<*> -> this.all == other.all + else -> this.all == other + } + + override fun hashCode(): Int = all.hashCode() + + override fun toString(): String = + "NonEmptySet(${all.joinToString()})" +} + +@JvmSynthetic +internal fun nonEmptySetOf(head: A, vararg t: A): NonEmptySet = + NonEmptySet(head, t.asIterable()) + +@JvmSynthetic +internal fun Iterable.toNonEmptySetOrNull(): NonEmptySet? { + val iterator = iterator() + if (!iterator.hasNext()) return null + return NonEmptySet(iterator.next(), Iterable { iterator }) +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt index e561777f53..eaa9fa85dd 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/OfferingToStateMapper.kt @@ -1,10 +1,16 @@ +@file:Suppress("TooManyFunctions") + package com.revenuecat.purchases.ui.revenuecatui.helpers import androidx.compose.material3.ColorScheme import com.revenuecat.purchases.Offering import com.revenuecat.purchases.paywalls.PaywallData +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData import com.revenuecat.purchases.ui.revenuecatui.PaywallMode +import com.revenuecat.purchases.ui.revenuecatui.components.PaywallAction +import com.revenuecat.purchases.ui.revenuecatui.components.style.StyleFactory import com.revenuecat.purchases.ui.revenuecatui.composables.PaywallIconName import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState import com.revenuecat.purchases.ui.revenuecatui.data.processed.PackageConfigurationType @@ -17,27 +23,30 @@ import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefault import com.revenuecat.purchases.ui.revenuecatui.extensions.createDefaultForIdentifiers import com.revenuecat.purchases.ui.revenuecatui.extensions.defaultTemplate import kotlin.Result +import com.revenuecat.purchases.ui.revenuecatui.helpers.Result as RcResult @Suppress("ReturnCount") -internal fun Offering.validatedLegacyPaywall( +internal fun Offering.validatedPaywall( currentColorScheme: ColorScheme, resourceProvider: ResourceProvider, -): PaywallValidationResult.Legacy { - val paywallData = this.paywall - ?: return PaywallValidationResult.Legacy( - PaywallData.createDefault( - availablePackages, - currentColorScheme, - resourceProvider, - ), - PaywallData.defaultTemplate, - PaywallValidationError.MissingPaywall, - ) +): PaywallValidationResult = + paywallComponents?.validate()?.let { result -> + // We need to either unwrap the success value, or wrap the errors in a fallback Paywall. + when (result) { + is RcResult.Success -> result.value + is RcResult.Error -> fallbackPaywall(currentColorScheme, resourceProvider, errors = result.value) + } + } ?: paywall?.validate(currentColorScheme, resourceProvider) + ?: fallbackPaywall(currentColorScheme, resourceProvider, error = PaywallValidationError.MissingPaywall) - val template = paywallData.validate().getOrElse { +internal fun PaywallData.validate( + currentColorScheme: ColorScheme, + resourceProvider: ResourceProvider, +): PaywallValidationResult.Legacy { + val template = validate().getOrElse { return PaywallValidationResult.Legacy( PaywallData.createDefaultForIdentifiers( - paywallData.config.packageIds, + config.packageIds, currentColorScheme, resourceProvider, ), @@ -46,11 +55,72 @@ internal fun Offering.validatedLegacyPaywall( ) } return PaywallValidationResult.Legacy( - paywallData, + this, template, ) } +private fun Offering.fallbackPaywall( + currentColorScheme: ColorScheme, + resourceProvider: ResourceProvider, + error: PaywallValidationError, +): PaywallValidationResult.Legacy = + fallbackPaywall( + currentColorScheme = currentColorScheme, + resourceProvider = resourceProvider, + errors = nonEmptyListOf(error), + ) + +private fun Offering.fallbackPaywall( + currentColorScheme: ColorScheme, + resourceProvider: ResourceProvider, + errors: NonEmptyList, +): PaywallValidationResult.Legacy = + PaywallValidationResult.Legacy( + PaywallData.createDefault( + availablePackages, + currentColorScheme, + resourceProvider, + ), + PaywallData.defaultTemplate, + errors, + ) + +@Suppress("MaxLineLength") +internal fun PaywallComponentsData.validate(): RcResult> = + defaultLocalization + // Check that the default localization is present in the localizations map. + .errorIfNull(PaywallValidationError.AllLocalizationsMissing(defaultLocaleIdentifier)) + .mapError { nonEmptyListOf(it) } + .map { defaultLocalization -> + // Build a NonEmptyMap, ensuring that we always have the default localization as fallback. + nonEmptyMapOf(defaultLocaleIdentifier to defaultLocalization, componentsLocalizations) + } + .flatMap { localizations -> + // We need to turn our NonEmptyMap into NonEmptyMap. + localizations.mapValues { (locale, map) -> + map.toNonEmptyMapOrNull() + .errorIfNull(PaywallValidationError.AllLocalizationsMissing(locale)) + .mapError { nonEmptyListOf(it) } + }.mapValuesOrAccumulate { it } + }.flatMap { localizations -> + // Use the StyleFactory to recursively create and validate all ComponentStyles. + val styleFactory = StyleFactory(localizations) + val actionHandler: suspend (PaywallAction) -> Unit = { /* TODO Move the action handler to the UI layer. */ } + val config = componentsConfig.base + zipOrAccumulate( + styleFactory.create(config.stack, actionHandler), + config.stickyFooter?.let { styleFactory.create(it, actionHandler) }.orSuccessfullyNull(), + ) { stack, stickyFooter -> + PaywallValidationResult.Components( + stack = stack, + stickyFooter = stickyFooter, + background = config.background, + locales = localizations.keys, + ) + } + } + @Suppress("ReturnCount") private fun PaywallData.validate(): Result { val template = validateTemplate() ?: return Result.failure(PaywallValidationError.InvalidTemplate(templateName)) @@ -162,10 +232,15 @@ internal fun Offering.toLegacyPaywallState( ) } -internal fun Offering.toComponentsPaywallState(validatedPaywallData: PaywallComponentsData): PaywallState = +internal fun Offering.toComponentsPaywallState( + validationResult: PaywallValidationResult.Components, +): PaywallState.Loaded.Components = PaywallState.Loaded.Components( + stack = validationResult.stack, + stickyFooter = validationResult.stickyFooter, + background = validationResult.background, offering = this, - data = validatedPaywallData, + locales = validationResult.locales, ) /** @@ -222,3 +297,6 @@ private fun PaywallData.LocalizedConfiguration.validateIcons(): PaywallValidatio private fun PaywallData.validateTemplate(): PaywallTemplate? { return PaywallTemplate.fromId(templateName) } + +private val PaywallComponentsData.defaultLocalization: Map? + get() = componentsLocalizations[defaultLocaleIdentifier] diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt index 2ab9ea5eac..fcd90886ea 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/PaywallValidationResult.kt @@ -1,21 +1,42 @@ package com.revenuecat.purchases.ui.revenuecatui.helpers import com.revenuecat.purchases.paywalls.PaywallData -import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData +import com.revenuecat.purchases.paywalls.components.common.Background +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.ui.revenuecatui.components.style.ComponentStyle import com.revenuecat.purchases.ui.revenuecatui.data.processed.PaywallTemplate import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError internal sealed interface PaywallValidationResult { - val error: PaywallValidationError? + val errors: NonEmptyList? data class Legacy( val displayablePaywall: PaywallData, val template: PaywallTemplate, - override val error: PaywallValidationError? = null, - ) : PaywallValidationResult + override val errors: NonEmptyList? = null, + ) : PaywallValidationResult { + constructor( + displayablePaywall: PaywallData, + template: PaywallTemplate, + error: PaywallValidationError, + ) : this( + displayablePaywall = displayablePaywall, + template = template, + errors = nonEmptyListOf(error), + ) + } data class Components( - val displayablePaywall: PaywallComponentsData, - override val error: PaywallValidationError? = null, - ) : PaywallValidationResult + val stack: ComponentStyle, + val stickyFooter: ComponentStyle?, + val background: Background, + /** + * All locales that this paywall supports, with `locales.head` being the default one. + */ + val locales: NonEmptySet, + ) : PaywallValidationResult { + // If a Components Paywall has an error, it will be reflected as a Legacy type so we can use the Legacy + // fallback. + override val errors: NonEmptyList? = null + } } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/Result.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/Result.kt index 2632f79ca7..f17e89076e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/Result.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/Result.kt @@ -28,6 +28,13 @@ internal val Result<*, *>.isSuccess: Boolean internal val Result<*, *>.isError: Boolean get() = this is Result.Error +/** + * Wrap this in a Result.Success if it's non-null. Otherwise, wrap [error] in a Result.Error. + */ +@JvmSynthetic +internal fun A?.errorIfNull(error: B): Result = + this?.let { Result.Success(it) } ?: Result.Error(error) + /** * Side effect to run when this Result represents an Error. */ @@ -49,6 +56,16 @@ internal inline fun Result.map(transform: (value: A) -> R): Resu is Result.Error -> this } +/** + * Maps this Result's success value into a new Result. + */ +@JvmSynthetic +internal inline fun Result.flatMap(transform: (value: A) -> Result): Result = + when (this) { + is Result.Success -> transform(value) + is Result.Error -> this + } + /** * Maps this Result's error value. */ diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt index 93dc1cb079..daee84e88e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/strings/PaywallValidationErrorStrings.kt @@ -15,4 +15,6 @@ internal object PaywallValidationErrorStrings { "Missing string localization for property with id: '%s', for locale: '%s'." const val MISSING_IMAGE_LOCALIZATION_WITH_LOCALE = "Missing image localization for property with id: '%s', for locale: '%s'." + const val ALL_LOCALIZATIONS_MISSING_FOR_LOCALE = + "All localizations for locale '%s' are missing." } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/LegacyPaywallDataValidationTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/LegacyPaywallDataValidationTest.kt index a80502d9f3..2be0fc2e0b 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/LegacyPaywallDataValidationTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/LegacyPaywallDataValidationTest.kt @@ -7,7 +7,8 @@ import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvid import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger -import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedLegacyPaywall +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult +import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedPaywall import io.mockk.mockkObject import io.mockk.verify import kotlinx.serialization.json.Json @@ -23,21 +24,22 @@ class LegacyPaywallDataValidationTest { fun `Validate an offering without paywall`() { val offering = TestData.offeringWithNoPaywall val paywallValidationResult = getPaywallValidationResult(offering) - assertThat(paywallValidationResult.error).isEqualTo(PaywallValidationError.MissingPaywall) + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo(PaywallValidationError.MissingPaywall) } @Test fun `Validate a valid paywall`() { val offering = TestData.template1Offering val paywallValidationResult = getPaywallValidationResult(offering) - assertThat(paywallValidationResult.error).isNull() + assertThat(paywallValidationResult.errors).isNull() } @Test fun `Validate a valid multi-tier paywall`() { val offering = TestData.template7Offering val paywallValidationResult = getPaywallValidationResult(offering) - assertThat(paywallValidationResult.error).isNull() + assertThat(paywallValidationResult.errors).isNull() } @Test @@ -47,10 +49,13 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = originalOffering.paywall!!.copy(templateName = templateName)) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate(paywallValidationResult.displayablePaywall) - assertThat(paywallValidationResult.error).isEqualTo(PaywallValidationError.InvalidTemplate(templateName)) + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()) + .isEqualTo(PaywallValidationError.InvalidTemplate(templateName)) } @Test @@ -68,9 +73,11 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate(paywallValidationResult.displayablePaywall) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.InvalidVariables(setOf("unrecognized_variable", "future_variable")) ) } @@ -97,9 +104,11 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate(paywallValidationResult.displayablePaywall) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.InvalidVariables(setOf("future_variable", "new_variable", "another_one")) ) } @@ -123,9 +132,11 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate(paywallValidationResult.displayablePaywall) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.InvalidIcons(setOf("an_unrecognized_icon")) ) } @@ -143,13 +154,15 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate( paywallValidationResult.displayablePaywall, // Skipping because there are none but they will show in the paywall from createPackageConfiguration skipPackageIds = true, ) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.MissingTiers ) } @@ -170,13 +183,15 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate( paywallValidationResult.displayablePaywall, // Skipping because there are none but they will show in the paywall from createPackageConfiguration skipPackageIds = true, ) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.MissingTierConfigurations(setOf("basic")) ) } @@ -199,9 +214,10 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) - assertThat(paywallValidationResult.error).isNull() + assertThat(paywallValidationResult.errors).isNull() verify { Logger.w("Missing images for tier(s): basic") } } @@ -220,18 +236,20 @@ class LegacyPaywallDataValidationTest { val offering = originalOffering.copy(paywall = paywall) val paywallValidationResult = getPaywallValidationResult(offering) + check(paywallValidationResult is PaywallValidationResult.Legacy) verifyPackages(paywallValidationResult.displayablePaywall, originalOffering.paywall!!) compareWithDefaultTemplate( paywallValidationResult.displayablePaywall, // Skipping because there are none but they will show in the paywall from createPackageConfiguration skipPackageIds = true, ) - assertThat(paywallValidationResult.error).isEqualTo( + assertThat(paywallValidationResult.errors?.size).isEqualTo(1) + assertThat(paywallValidationResult.errors?.first()).isEqualTo( PaywallValidationError.MissingTierConfigurations(setOf("basic")) ) } - private fun getPaywallValidationResult(offering: Offering) = offering.validatedLegacyPaywall( + private fun getPaywallValidationResult(offering: Offering) = offering.validatedPaywall( currentColorScheme = TestData.Constants.currentColorScheme, resourceProvider = MockResourceProvider() ) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt new file mode 100644 index 0000000000..896e822c26 --- /dev/null +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallComponentDataValidationTests.kt @@ -0,0 +1,217 @@ +package com.revenuecat.purchases.ui.revenuecatui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.revenuecat.purchases.Offering +import com.revenuecat.purchases.paywalls.components.StackComponent +import com.revenuecat.purchases.paywalls.components.TextComponent +import com.revenuecat.purchases.paywalls.components.common.Background +import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConfig +import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData +import com.revenuecat.purchases.paywalls.components.properties.ColorInfo +import com.revenuecat.purchases.paywalls.components.properties.ColorScheme +import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider +import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData +import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError.AllLocalizationsMissing +import com.revenuecat.purchases.ui.revenuecatui.errors.PaywallValidationError.MissingStringLocalization +import com.revenuecat.purchases.ui.revenuecatui.helpers.PaywallValidationResult +import com.revenuecat.purchases.ui.revenuecatui.helpers.validatedPaywall +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL + +class PaywallComponentDataValidationTests { + + @Test + fun `Head of localizations map should always be the default locale`() { + // Arrange + val defaultLocale = LocaleId("en_US") + // A LinkedHashMap is ordered. Our defaultLocale is not the first nor the last item. + val localizations = LinkedHashMap>(3, 1f).apply { + put(LocaleId("nl_NL"), mapOf(LocalizationKey("key") to LocalizationData.Text("waarde"))) + put(defaultLocale, mapOf(LocalizationKey("key") to LocalizationData.Text("value"))) + put(LocaleId("es_ES"), mapOf(LocalizationKey("key") to LocalizationData.Text("valor"))) + } + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = emptyList()), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + check(validated is PaywallValidationResult.Components) + assertEquals(defaultLocale, validated.locales.head) + } + + @Test + fun `Should return AllLocalizationsMissing with Legacy fallback if all locales are missing`() { + // Arrange + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = emptyList()), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + // We have no localizations. + componentsLocalizations = emptyMap(), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + check(validated is PaywallValidationResult.Legacy) + assertNotNull(validated.errors) + assertEquals(validated.errors?.size, 1) + assertEquals(validated.errors?.first(), AllLocalizationsMissing(defaultLocale)) + } + + @Test + fun `Should accumulate errors with Legacy fallback if some localizations are missing`() { + // Arrange + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + components = listOf( + TextComponent( + text = LocalizationKey("key1"), + color = ColorScheme(ColorInfo.Hex(Color.White.toArgb())), + ), + TextComponent( + text = LocalizationKey("key2"), + color = ColorScheme(ColorInfo.Hex(Color.White.toArgb())), + ), + ) + ), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + // We're missing some localizations for nl_NL and es_ES. + componentsLocalizations = mapOf( + LocaleId("nl_NL") to mapOf( + LocalizationKey("key1") to LocalizationData.Text("waarde1"), + ), + defaultLocale to mapOf( + LocalizationKey("key1") to LocalizationData.Text("value1"), + LocalizationKey("key2") to LocalizationData.Text("value2"), + ), + LocaleId("es_ES") to mapOf( + LocalizationKey("key2") to LocalizationData.Text("valor2"), + ), + ), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + assertTrue(validated is PaywallValidationResult.Legacy) + assertNotNull(validated.errors) + assertEquals(validated.errors!!.size, 2) + assertTrue( + validated.errors!!.contains(MissingStringLocalization(LocalizationKey("key2"), LocaleId("nl_NL"))) + ) + assertTrue( + validated.errors!!.contains(MissingStringLocalization(LocalizationKey("key1"), LocaleId("es_ES"))) + ) + } + + @Test + fun `Should not fail if missing localizations are not used`() { + // Arrange + val defaultLocale = LocaleId("en_US") + val data = PaywallComponentsData( + templateName = "template", + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent( + // There are no TextComponents using the localizations. + components = emptyList() + ), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + // We're missing some localizations for nl_NL and es_ES. + componentsLocalizations = mapOf( + LocaleId("nl_NL") to mapOf( + LocalizationKey("key1") to LocalizationData.Text("waarde1"), + ), + defaultLocale to mapOf( + LocalizationKey("key1") to LocalizationData.Text("value1"), + LocalizationKey("key2") to LocalizationData.Text("value2"), + ), + LocaleId("es_ES") to mapOf( + LocalizationKey("key2") to LocalizationData.Text("valor2"), + ), + ), + defaultLocaleIdentifier = defaultLocale, + ) + val offering = Offering( + identifier = "identifier", + serverDescription = "serverDescription", + metadata = emptyMap(), + availablePackages = emptyList(), + paywallComponents = data, + ) + + // Act + val validated = offering.validatedPaywall(TestData.Constants.currentColorScheme, MockResourceProvider()) + + // Assert + assertTrue(validated is PaywallValidationResult.Components) + assertNull(validated.errors) + } +} diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponentsLocaleTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponentsLocaleTests.kt index bcf261d9e4..0a0a59489c 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponentsLocaleTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/LoadedPaywallComponentsLocaleTests.kt @@ -25,8 +25,10 @@ import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsConf import com.revenuecat.purchases.paywalls.components.common.PaywallComponentsData import com.revenuecat.purchases.paywalls.components.properties.ColorInfo import com.revenuecat.purchases.paywalls.components.properties.ColorScheme -import com.revenuecat.purchases.ui.revenuecatui.data.PaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.getOrThrow import com.revenuecat.purchases.ui.revenuecatui.helpers.localeChangingTest +import com.revenuecat.purchases.ui.revenuecatui.helpers.toComponentsPaywallState +import com.revenuecat.purchases.ui.revenuecatui.helpers.validate import org.junit.Rule import org.junit.Test import org.junit.experimental.runners.Enclosed @@ -115,7 +117,7 @@ internal class LoadedPaywallComponentsLocaleTests { class TestActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val state = PaywallState.Loaded.Components(offering, paywallComponents) + val state = offering.toComponentsPaywallState(paywallComponents.validate().getOrThrow()) setContent { LoadedPaywallComponents(state = state) } } } @@ -126,11 +128,11 @@ internal class LoadedPaywallComponentsLocaleTests { @get:Rule val composeTestRule = createComposeRule() - + @Test fun `Should propagate locale changes without Activity recreation`(): Unit = with(composeTestRule) { localeChangingTest( - arrange = { PaywallState.Loaded.Components(offering, paywallComponents) }, + arrange = { offering.toComponentsPaywallState(paywallComponents.validate().getOrThrow()) }, act = { state -> LoadedPaywallComponents(state = state) }, assert = { localeController -> localeController.setLocale("en-US") @@ -142,7 +144,7 @@ internal class LoadedPaywallComponentsLocaleTests { .assertIsDisplayed() } ) - + } } } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt index 9205e79ecd..61d806b0c6 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewTests.kt @@ -91,7 +91,11 @@ class TextComponentViewTests { dark = ColorInfo.Hex(expectedDarkColor.toArgb()), ), ) - val state = FakePaywallState(component) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + component + ) themeChangingTest( arrange = { @@ -132,7 +136,11 @@ class TextComponentViewTests { dark = ColorInfo.Hex(expectedDarkColor.toArgb()), ), ) - val state = FakePaywallState(component) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + component + ) themeChangingTest( arrange = { @@ -169,7 +177,12 @@ class TextComponentViewTests { val size = Size(Fit, Fit) val largeTextComponent = TextComponent(text = textId, color = color, fontSize = FontSize.HEADING_L, size = size) val smallTextComponent = TextComponent(text = textId, color = color, fontSize = FontSize.BODY_S, size = size) - val state = FakePaywallState(largeTextComponent, smallTextComponent) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + largeTextComponent, + smallTextComponent + ) setContent { val largeTextStyle = styleFactory.create( largeTextComponent, @@ -227,7 +240,11 @@ class TextComponentViewTests { ), ) ) - val state = FakePaywallState(component) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + component + ) val style = styleFactory.create(component, actionHandler).getOrThrow() as TextComponentStyle // Act @@ -274,7 +291,11 @@ class TextComponentViewTests { ), ) ) - val state = FakePaywallState(component) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeIdEnUs, + component + ) val style = styleFactory.create(component, actionHandler).getOrThrow() as TextComponentStyle // Act diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewWindowTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewWindowTests.kt index 0e90390b76..44d68711d5 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewWindowTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/text/TextComponentViewWindowTests.kt @@ -99,7 +99,11 @@ internal class TextComponentViewWindowTests { ) ) ) - val state = FakePaywallState(component) + val state = FakePaywallState( + localizations = localizations, + defaultLocaleIdentifier = localeId, + component + ) val styleFactory = StyleFactory(localizations) val style = styleFactory.create(component, { }).getOrThrow() as TextComponentStyle } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt index ac5e8365fa..175f036168 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/FakePaywallState.kt @@ -72,6 +72,6 @@ internal fun FakePaywallState( availablePackages = packages, paywallComponents = data, ) - - return PaywallState.Loaded.Components(offering, data) + val validated = data.validate().getOrThrow() + return offering.toComponentsPaywallState(validated) } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ResultTests.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ResultTests.kt index fc8eb653a0..8000444090 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ResultTests.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/helpers/ResultTests.kt @@ -85,7 +85,66 @@ class ResultTests { } @Test - fun `mapValuesOrAccumulate should transform a map of successful results`() { + fun `mapValuesOrAccumulate on Map should transform a map of successful results`() { + // Arrange + val map: Map>> = mapOf( + "first" to Result.Success(1), + "second" to Result.Success(2), + "third" to Result.Success(3) + ) + + // Act + val actual = map.mapValuesOrAccumulate { it * 2 } + + // Assert + assertThat(actual).isInstanceOf(Result.Success::class.java) + val successValue = actual.getOrNull() + assertThat(successValue?.toMap()).containsExactlyInAnyOrderEntriesOf( + mapOf( + "first" to 2, + "second" to 4, + "third" to 6 + ) + ) + } + + @Test + fun `mapValuesOrAccumulate on Map should accumulate errors from multiple results`() { + // Arrange + val map: Map>> = mapOf( + "first" to Result.Success(1), + "second" to Result.Error(nonEmptyListOf("error1")), + "third" to Result.Error(nonEmptyListOf("error2")) + ) + + // Act + val actual = map.mapValuesOrAccumulate { it * 2 } + + // Assert + assertThat(actual).isInstanceOf(Result.Error::class.java) + assertThat(actual.errorOrNull()?.toList()).containsExactly("error1", "error2") + } + + @Test + fun `mapValuesOrAccumulate on Map should work with single entry map`() { + // Arrange + val map = mapOf( + "single" to Result.Success(1) + ) as Map>> + + // Act + val actual = map.mapValuesOrAccumulate { it * 2 } + + // Assert + assertThat(actual).isInstanceOf(Result.Success::class.java) + val successValue = actual.getOrNull() + assertThat(successValue?.toMap()).containsExactlyInAnyOrderEntriesOf( + mapOf("single" to 2) + ) + } + + @Test + fun `mapValuesOrAccumulate on NonEmptyMap should transform a map of successful results`() { // Arrange val map: NonEmptyMap>> = nonEmptyMapOf( "first" to Result.Success(1), @@ -111,7 +170,7 @@ class ResultTests { } @Test - fun `mapValuesOrAccumulate should accumulate errors from multiple results`() { + fun `mapValuesOrAccumulate on NonEmptyMap should accumulate errors from multiple results`() { // Arrange val map: NonEmptyMap>> = nonEmptyMapOf( "first" to Result.Success(1), @@ -130,7 +189,7 @@ class ResultTests { } @Test - fun `mapValuesOrAccumulate should work with single entry map`() { + fun `mapValuesOrAccumulate on NonEmptyMap should work with single entry map`() { // Arrange val map = nonEmptyMapOf( "single" to Result.Success(1)