From d6cf123e99c96645c9b8c2472fb58bc6aee13662 Mon Sep 17 00:00:00 2001 From: Al Kent Date: Thu, 18 Apr 2024 10:04:10 -0500 Subject: [PATCH] Adding preview snapshot test apis --- gradle/libs.versions.toml | 1 + paparazzi/api/paparazzi.api | 38 +++++ paparazzi/build.gradle | 3 + .../app/cash/paparazzi/preview/Snapshot.kt | 104 ++++++++++++++ .../java/app/cash/paparazzi/preview/Utils.kt | 133 ++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt create mode 100644 paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c6209fa4d..b48c961209 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ bytebuddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "by bytebuddy-core = { module = "net.bytebuddy:byte-buddy", version.ref = "bytebuddy" } compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +composeUi-foundation = { module = "androidx.compose.foundation:foundation" } composeUi-material = { module = "androidx.compose.material:material", version.ref = "compose" } composeUi-uiTooling = { module = "androidx.compose.ui:ui-tooling" } diff --git a/paparazzi/api/paparazzi.api b/paparazzi/api/paparazzi.api index 01a6bf3947..b451ff9db0 100644 --- a/paparazzi/api/paparazzi.api +++ b/paparazzi/api/paparazzi.api @@ -235,3 +235,41 @@ public final class app/cash/paparazzi/accessibility/AccessibilityRenderExtension public fun renderView (Landroid/view/View;)Landroid/view/View; } +public final class app/cash/paparazzi/preview/ComposableSingletons$SnapshotKt { + public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$SnapshotKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$paparazzi ()Lkotlin/jvm/functions/Function3; +} + +public final class app/cash/paparazzi/preview/ComposableSingletons$UtilsKt { + public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$UtilsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$paparazzi ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$paparazzi ()Lkotlin/jvm/functions/Function3; +} + +public final class app/cash/paparazzi/preview/DefaultLocaleRule : org/junit/rules/TestRule { + public static final field $stable I + public fun (Ljava/lang/String;)V + public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; + public final fun getLocale ()Ljava/lang/String; +} + +public class app/cash/paparazzi/preview/PaparazziValuesProvider : com/google/testing/junit/testparameterinjector/TestParameter$TestParameterValuesProvider { + public static final field $stable I + public fun (Ljava/util/List;)V + public fun provideValues ()Ljava/util/List; +} + +public final class app/cash/paparazzi/preview/SnapshotKt { + public static final fun deviceConfig (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Lapp/cash/paparazzi/DeviceConfig;)Lapp/cash/paparazzi/DeviceConfig; + public static synthetic fun deviceConfig$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Lapp/cash/paparazzi/DeviceConfig;ILjava/lang/Object;)Lapp/cash/paparazzi/DeviceConfig; + public static final fun flatten (Ljava/util/List;)Ljava/util/List; + public static final fun locale (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;)Ljava/lang/String; + public static final fun snapshot (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;ZLkotlin/jvm/functions/Function3;)V + public static synthetic fun snapshot$default (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;ZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V +} + diff --git a/paparazzi/build.gradle b/paparazzi/build.gradle index 36a9d894a5..4dfb91297b 100644 --- a/paparazzi/build.gradle +++ b/paparazzi/build.gradle @@ -32,6 +32,7 @@ dependencies { testImplementationAarAsJar libs.androidx.compose.ui.android compileOnlyAarAsJar libs.androidx.compose.ui.android compileOnlyAarAsJar libs.androidx.activity + compileOnlyAarAsJar libs.composeUi.foundation implementation libs.bytebuddy.agent implementation libs.bytebuddy.core @@ -47,10 +48,12 @@ dependencies { api libs.guava api libs.kotlinx.coroutines.android api libs.okio + api libs.testParameterInjector api platform(libs.kotlin.bom) implementation libs.moshi.core implementation libs.moshi.adapters implementation libs.moshi.kotlinReflect + implementation projects.paparazziAnnotations def osName = System.getProperty("os.name").toLowerCase(Locale.US) def osLabel diff --git a/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt b/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt new file mode 100644 index 0000000000..8c06dcece6 --- /dev/null +++ b/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt @@ -0,0 +1,104 @@ +// Copyright Square, Inc. +package app.cash.paparazzi.preview + +import androidx.compose.runtime.Composable +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.annotations.PaparazziPreviewData +import com.google.testing.junit.testparameterinjector.TestParameter.TestParameterValuesProvider +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.util.Locale + +/** + * Take a snapshot of the given [previewData]. + */ +public fun Paparazzi.snapshot( + previewData: PaparazziPreviewData, + name: String? = null, + localInspectionMode: Boolean = true, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + when (previewData) { + is PaparazziPreviewData.Default -> snapshotDefault(previewData, name, localInspectionMode, wrapper) + is PaparazziPreviewData.Provider<*> -> snapshotProvider(previewData, name, localInspectionMode, wrapper) + is PaparazziPreviewData.Empty -> Unit + is PaparazziPreviewData.Error -> throw Exception(previewData.message) + } +} + +/** + * Generate a Paparazzi DeviceConfig for the given preview + * using the given [default] DeviceConfig. + * + * default: The IDE renders a preview with a higher resolution than + * the default device set by Paparazzi (which is currently Nexus 5). Defaulting to + * a larger device brings the previews and snapshots closer in parity. + */ +public fun PaparazziPreviewData.deviceConfig( + default: DeviceConfig = DeviceConfig.PIXEL_5 +): DeviceConfig = when (this) { + is PaparazziPreviewData.Default -> preview.deviceConfig(default) + is PaparazziPreviewData.Provider<*> -> preview.deviceConfig(default) + else -> default +} + +/** + * Returns a locale for the given preview, or null if error or empty. + */ +public fun PaparazziPreviewData.locale(): String? = when (this) { + is PaparazziPreviewData.Default -> preview.locale + is PaparazziPreviewData.Provider<*> -> preview.locale + else -> null +} + +/** + * Convert a list of generated [PaparazziPreviewData] + * to a flat list of [PaparazziPreviewData]s. + */ +public fun List.flatten(): List = flatMap { + when (it) { + is PaparazziPreviewData.Provider<*> -> List(it.previewParameter.values.count()) { i -> + it.withPreviewParameterIndex(i) + } + else -> listOf(it) + } +} + +/** + * A `@TestParameter` values provider for the given [annotations]. + * + * Example usage: + * ``` + * private class ValuesProvider : PaparazziValuesProvider(paparazziAnnotations) + * ``` + */ +public open class PaparazziValuesProvider( + private val annotations: List +) : TestParameterValuesProvider { + override fun provideValues(): List = annotations.flatten() +} + +/** + * Enforce a particular default locale for a test. Resets back to default on completion. + */ +public class DefaultLocaleRule(public val locale: String?) : TestRule { + override fun apply( + base: Statement, + description: Description + ): Statement { + return object : Statement() { + override fun evaluate() { + val default = Locale.getDefault() + + try { + locale?.let { Locale.setDefault(Locale.forLanguageTag(it)) } + base.evaluate() + } finally { + Locale.setDefault(default) + } + } + } + } +} diff --git a/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt b/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt new file mode 100644 index 0000000000..b70b87e662 --- /dev/null +++ b/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt @@ -0,0 +1,133 @@ +// Copyright Square, Inc. +package app.cash.paparazzi.preview + +import android.content.res.Configuration +import android.util.DisplayMetrics +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.annotations.PaparazziPreviewData +import app.cash.paparazzi.annotations.PreviewData +import com.android.resources.NightMode +import com.android.resources.UiMode +import java.util.Locale +import kotlin.math.roundToInt + +internal fun String.deviceConfig() = when (this) { + "id:Nexus 7" -> DeviceConfig.NEXUS_7 + "id:Nexus 7 2013" -> DeviceConfig.NEXUS_7_2012 + "id:Nexus 5" -> DeviceConfig.NEXUS_5 + "id:Nexus 6" -> DeviceConfig.NEXUS_7 + "id:Nexus 9" -> DeviceConfig.NEXUS_10 + "name:Nexus 10" -> DeviceConfig.NEXUS_10 + "id:Nexus 5X" -> DeviceConfig.NEXUS_5 + "id:Nexus 6P" -> DeviceConfig.NEXUS_7 + "id:pixel_c" -> DeviceConfig.PIXEL_C + "id:pixel" -> DeviceConfig.PIXEL + "id:pixel_xl" -> DeviceConfig.PIXEL_XL + "id:pixel_2" -> DeviceConfig.PIXEL_2 + "id:pixel_2_xl" -> DeviceConfig.PIXEL_2_XL + "id:pixel_3" -> DeviceConfig.PIXEL_3 + "id:pixel_3_xl" -> DeviceConfig.PIXEL_3_XL + "id:pixel_3a" -> DeviceConfig.PIXEL_3A + "id:pixel_3a_xl" -> DeviceConfig.PIXEL_3A_XL + "id:pixel_4" -> DeviceConfig.PIXEL_4 + "id:pixel_4_xl" -> DeviceConfig.PIXEL_4_XL + "id:pixel_5" -> DeviceConfig.PIXEL_5 + "id:pixel_6" -> DeviceConfig.PIXEL_6 + "id:pixel_6_pro" -> DeviceConfig.PIXEL_6_PRO + "id:wearos_small_round" -> DeviceConfig.WEAR_OS_SMALL_ROUND + "id:wearos_square" -> DeviceConfig.WEAR_OS_SQUARE + else -> null +} + +internal fun Int.uiMode() = when (this and Configuration.UI_MODE_TYPE_MASK) { + Configuration.UI_MODE_TYPE_NORMAL -> UiMode.NORMAL + Configuration.UI_MODE_TYPE_CAR -> UiMode.CAR + Configuration.UI_MODE_TYPE_DESK -> UiMode.DESK + Configuration.UI_MODE_TYPE_APPLIANCE -> UiMode.APPLIANCE + Configuration.UI_MODE_TYPE_WATCH -> UiMode.WATCH + Configuration.UI_MODE_TYPE_VR_HEADSET -> UiMode.VR_HEADSET + else -> null +} + +internal fun Int.nightMode() = when (this and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> NightMode.NOTNIGHT + Configuration.UI_MODE_NIGHT_YES -> NightMode.NIGHT + else -> null +} + +internal fun String.localeQualifierString() = + Locale.forLanguageTag(this).run { + "$language-r$country" + } + +internal fun PreviewData?.deviceConfig(defaultDeviceConfig: DeviceConfig) = + (this?.device?.deviceConfig() ?: defaultDeviceConfig).let { config -> + config.copy( + screenWidth = this?.widthDp?.toPx(config.density.dpiValue) ?: config.screenWidth, + screenHeight = this?.heightDp?.toPx(config.density.dpiValue) ?: config.screenHeight, + fontScale = this?.fontScale ?: config.fontScale, + uiMode = this?.uiMode?.uiMode() ?: config.uiMode, + nightMode = this?.uiMode?.nightMode() ?: config.nightMode, + locale = this?.locale?.localeQualifierString() ?: config.locale + ) + } + +private fun Int.toPx(dpi: Int) = + (this * (dpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + +internal fun Paparazzi.snapshotDefault( + previewData: PaparazziPreviewData.Default, + name: String?, + localInspectionMode: Boolean, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + snapshot(name) { + PreviewWrapper(previewData.preview.backgroundColor, localInspectionMode) { + wrapper { previewData.composable() } + } + } +} + +internal fun Paparazzi.snapshotProvider( + previewData: PaparazziPreviewData.Provider, + name: String?, + localInspectionMode: Boolean, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + val paramValue = previewData.previewParameter.values + .elementAt(previewData.previewParameter.index) + + snapshot(name) { + PreviewWrapper(previewData.preview.backgroundColor, localInspectionMode) { + wrapper { previewData.composable(paramValue) } + } + } +} + +@Composable +private fun PreviewWrapper( + backgroundColor: String?, + localInspectionMode: Boolean, + content: @Composable BoxScope.() -> Unit +) { + CompositionLocalProvider(LocalInspectionMode provides localInspectionMode) { + Box( + modifier = Modifier + .then( + backgroundColor?.toLong(16) + ?.let { Modifier.background(Color(it)) } + ?: Modifier + ), + content = content + ) + } +}