Skip to content

Commit

Permalink
Merge pull request #34 from sergio-sastre/release/0.4.0
Browse files Browse the repository at this point in the history
0.4.0
  • Loading branch information
sergio-sastre authored Oct 30, 2024
2 parents faf1cea + 48bd625 commit 149ceaf
Show file tree
Hide file tree
Showing 30 changed files with 2,716 additions and 164 deletions.
69 changes: 63 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ but does not work with
```kotlin
dependencies {
// jvm tests (e.g. with Roborazzi & Paparazzi)
testImplementation("io.github.sergio-sastre.ComposablePreviewScanner:android:0.3.2")
testImplementation("io.github.sergio-sastre.ComposablePreviewScanner:android:<version>")

// instrumentation tests (e.g. with Shot, Dropshots & Android-Testify)
debugImplementation("io.github.sergio-sastre.ComposablePreviewScanner:android:0.3.2")
debugImplementation("io.github.sergio-sastre.ComposablePreviewScanner:android:<version>")

// compose multiplatform (jvm-targets)
testImplementation("io.github.sergio-sastre.ComposablePreviewScanner:jvm:0.3.2")
testImplementation("io.github.sergio-sastre.ComposablePreviewScanner:jvm:<version>")
}
```

Expand Down Expand Up @@ -187,10 +187,28 @@ object ComposablePreviewProvider : TestParameter.TestParameterValuesProvider {

4. Map the PreviewInfo and PaparazziConfig values. For instance, you can use a custom class for that.
```kotlin

// The DevicePreviewInfoParser used in this method is available since ComposablePreviewScanner 0.4.0
object DeviceConfigBuilder {
fun build(previewDevice: String): DeviceConfig {
val device = DevicePreviewInfoParser.parse(previewDevice) ?: return DeviceConfig()
return DeviceConfig(
screenHeight = device.dimensions.height.toInt(),
screenWidth = device.dimensions.width.toInt(),
xdpi = device.densityDpi, // not 100% precise
ydpi = device.densityDpi, // not 100% precise
ratio = ScreenRatio.valueOf(device.screenRatio.name),
size = ScreenSize.valueOf(device.screenSize.name),
density = Density(device.densityDpi),
screenRound = ScreenRound.valueOf(device.shape.name)
)
}
}

object PaparazziPreviewRule {
fun createFor(preview: ComposablePreview<AndroidPreviewInfo>): Paparazzi =
Paparazzi(
deviceConfig = PIXEL_4A.copy(
deviceConfig = DeviceConfigBuilder.build(preview.previewInfo.device).copy(
nightMode =
when(preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
true -> NightMode.NIGHT
Expand All @@ -216,7 +234,9 @@ class PreviewTestParameterTests(

@Test
fun snapshot() {
paparazzi.snapshot {
// Recommended for more meaningful screenshot file names. See #Advanced Usage
val screenshotId = AndroidPreviewScreenshotIdBuilder(preview).ignoreClassName().build()
paparazzi.snapshot(name = screenshotId) {
preview()
}
}
Expand Down Expand Up @@ -258,6 +278,11 @@ object RoborazziOptionsMapper {

object RobolectricPreviewInfosApplier {
fun applyFor(preview: ComposablePreview<AndroidPreviewInfo>) {
// Set the device from the preview info. Available since ComposablePreviewScanner 0.4.0
RobolectricDeviceQualifierBuilder.build(preview.previewInfo)?.run {
RuntimeEnvironment.setQualifiers(this)
}

val uiMode = nightMode =
when(preview.previewInfo.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES) {
true -> "+night"
Expand Down Expand Up @@ -297,8 +322,11 @@ class PreviewParameterizedTests(
@Test
fun snapshot() {
RobolectricPreviewInfosApplier.applyFor(preview)


// Recommended for more meaningful screenshot file names. See #Advanced Usage
val screenshotId = AndroidPreviewScreenshotIdBuilder(preview).build()
captureRoboImage(
filePath = "${screenshotId}.png",
roborazziOptions = RoborazziOptionsMapper.createFor(preview)
) {
preview()
Expand Down Expand Up @@ -424,6 +452,7 @@ Let's say we want to enable some custom Dropshots Config for some Previews, for
> So for this case, you'd have to convert locale "b+zh+Hans+CN" to "zh-Hans-CN" in order to use it with AndroidUiTestingUtils
## Advanced Usage
### Screenshot File Names
ComposablePreviewScanner also provides a class to customize the name of the generated screenshots based on its Preview Info.
By default, it does not include the Preview Info in the screenshot file name if it is the same as its default value, but it can be configured to behave differently.
That means, for @Preview(showBackground = false), showBackground would not be included in the screenshot file name since it is the default.
Expand Down Expand Up @@ -476,6 +505,34 @@ class MyClass {
```
createScreenshotIdFor(preview) will generate the following id: "MyClass.MyComposable.FONT_1_5f_WITHOUT_BACKGROUND"

### Parsing Preview Device String
Since 0.4.0, ComposablePreviewScanner also provides `DevicePreviewInfoParser.parse(device: String)`
which returns a `Device` object containing all the necessary information to support different devices in your Roborazzi & Paparazzi screenshot tests!

It can parse ALL possible combinations of "device strings" up to Android Studio Lady Bug, namely:
```kotlin
// The over 80 devices supported either by id and/or name, for instance:
@Preview(device = "id:pixel_9_pro")
@Preview(device = "name:Pixel 9 Pro")
@Preview(device = "spec:parent=pixel_9_pro, orientation=landscape, navigation=buttons")

// And custom devices
@Preview(device = "spec:width = 411dp, height = 891dp, orientation = landscape, dpi = 420, isRound = false, chinSize = 0dp, cutout = corner")
@Preview(device = "spec:id=reference_desktop,shape=Normal,width=1920,height=1080,unit=px,dpi=160") // in pixels
@Preview(device = "spec:id=reference_desktop,shape=Normal,width=1920,height=1080,unit=dp,dpi=160") // in dp
...
```

If you are using Roborazzi, you can streamline the process by just calling
```kotlin
RobolectricDeviceQualifierBuilder.build(preview.previewInfo)?.run {
RuntimeEnvironment.setQualifiers(this)
}
```
before setting any other Robolectric 'cumulative qualifier' in your tests.

For further info on how to use them, see [Roborazzi](#roborazzi) and [Paparazzi](#paparazzi) sections respectively.

## How It works
This library is written on top of [ClassGraph](https://github.com/classgraph/classgraph), an uber-fast parallelized classpath scanner.

Expand Down
2 changes: 1 addition & 1 deletion android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ publishing {
}
groupId = "sergio.sastre.composable.preview.scanner"
artifactId = "android"
version = "0.3.2"
version = "0.4.0"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package sergio.sastre.composable.preview.scanner.android.device

import sergio.sastre.composable.preview.scanner.android.device.domain.Cutout
import sergio.sastre.composable.preview.scanner.android.device.domain.Cutout.NONE
import sergio.sastre.composable.preview.scanner.android.device.domain.Device
import sergio.sastre.composable.preview.scanner.android.device.domain.Dimensions
import sergio.sastre.composable.preview.scanner.android.device.domain.Navigation.GESTURE
import sergio.sastre.composable.preview.scanner.android.device.domain.Orientation.LANDSCAPE
import sergio.sastre.composable.preview.scanner.android.device.domain.Orientation.PORTRAIT
import sergio.sastre.composable.preview.scanner.android.device.domain.GetDeviceByIdentifier.Companion.findByDeviceId
import sergio.sastre.composable.preview.scanner.android.device.domain.GetDeviceByIdentifier.Companion.findByDeviceName
import sergio.sastre.composable.preview.scanner.android.device.domain.Navigation
import sergio.sastre.composable.preview.scanner.android.device.domain.Orientation
import sergio.sastre.composable.preview.scanner.android.device.domain.Shape
import sergio.sastre.composable.preview.scanner.android.device.domain.Type
import sergio.sastre.composable.preview.scanner.android.device.domain.Unit
import sergio.sastre.composable.preview.scanner.android.device.types.DEFAULT
import sergio.sastre.composable.preview.scanner.android.device.types.Automotive
import sergio.sastre.composable.preview.scanner.android.device.types.Desktop
import sergio.sastre.composable.preview.scanner.android.device.types.GenericDevices
import sergio.sastre.composable.preview.scanner.android.device.types.Phone
import sergio.sastre.composable.preview.scanner.android.device.types.Tablet
import sergio.sastre.composable.preview.scanner.android.device.types.Television
import sergio.sastre.composable.preview.scanner.android.device.types.Wear

object DevicePreviewInfoParser {
fun parse(device: String): Device? {
if (device == "") return DEFAULT

if (device.startsWith("spec:")) {
return GetCustomDevice.from(device)
}

if (device.startsWith("id:")) {
return GetDeviceById.from(device)
}

if (device.startsWith("name:")) {
return GetDeviceByName.from(device)
}

return null
}
}

private object GetDeviceById {
fun from(device: String): Device? {
val id = device.removePrefix("id:")
return findByDeviceId<Phone>(id)?.device
?: findByDeviceId<Tablet>(id)?.device
?: findByDeviceId<Wear>(id)?.device
?: findByDeviceId<Desktop>(id)?.device
?: findByDeviceId<Automotive>(id)?.device
?: findByDeviceId<Television>(id)?.device
?: findByDeviceId<GenericDevices>(id)?.device
}
}

private object GetDeviceByName {
fun from(device: String): Device? {
val name = device.removePrefix("name:")
return findByDeviceName<Phone>(name)?.device
?: findByDeviceName<Tablet>(name)?.device
?: findByDeviceName<Wear>(name)?.device
?: findByDeviceName<Desktop>(name)?.device
?: findByDeviceName<Automotive>(name)?.device
?: findByDeviceName<Television>(name)?.device
?: findByDeviceName<GenericDevices>(name)?.device
}
}

private object GetCustomDevice {
fun from(device: String): Device {
val spec = device.removePrefix("spec:")
.splitToSequence(",")
.map { it.split("=", limit = 2).map { value -> value.trim() } }
.associateBy({ it[0] }, { it[1] })

val parent = spec["parent"]
if (parent != null) {
return buildDeviceFromParent(
parent = parent,
orientation = spec["orientation"],
navigation = spec["navigation"]
)
}

// This is deprecated... but needed for old configs
val type = spec["id"]
val typeValue = when (type) {
"reference_desktop" -> Type.DESKTOP
"reference_tablet" -> Type.TABLET
"reference_phone" -> Type.PHONE
"reference_foldable" -> Type.FOLDABLE
else -> null
}

val dimensions = dimensions(spec)

val roundShape = (spec["isRound"]?.toBoolean() ?: spec["shape"]?.equals("Round"))
val roundShapeValue = when (roundShape) {
true -> Shape.ROUND
false -> Shape.NOTROUND
null -> Shape.NOTROUND
}

val dpiValue = spec["dpi"]?.toIntOrNull() ?: 420

val orientation = spec["orientation"]
val orientationValue =
orientation?.let { Orientation.entries.find { it.value == orientation } }
?: if (dimensions.height >= dimensions.width) PORTRAIT else LANDSCAPE

val chinSize = spec["chinSize"]?.removeSuffix("dp")?.toIntOrNull() ?: 0

val navigation = spec["navigation"]
val navigationValue =
navigation?.let { Navigation.entries.find { it.value == navigation } } ?: GESTURE

val cutout = spec["cutout"]
val cutoutValue = cutout?.let { Cutout.entries.find { it.value == cutout } } ?: NONE

return Device(
dimensions = dimensions,
shape = roundShapeValue,
densityDpi = dpiValue,
type = typeValue,
orientation = orientationValue,
chinSize = chinSize,
navigation = navigationValue,
cutout = cutoutValue,
)
}

private fun buildDeviceFromParent(
parent: String,
orientation: String?,
navigation: String?,
): Device {
val parentDevice = requireNotNull(GetDeviceById.from(parent))
val orientationValue = orientation?.let {
Orientation.entries.find { it.value == orientation }
} ?: parentDevice.orientation

val navigationValue = navigation?.let {
Navigation.entries.find { it.value == navigation }
} ?: parentDevice.navigation

return parentDevice.copy(orientation = orientationValue, navigation = navigationValue)
}

private fun dimensions(spec: Map<String, String>): Dimensions {
val screenHeight = spec["height"]
val heightValue: Float =
screenHeight?.removeSuffix("dp")?.removeSuffix("px")?.toFloatOrNull()
?: throw IllegalArgumentException("height can never be null")

val screenWidth = spec["width"]
val widthValue: Float =
screenWidth?.removeSuffix("dp")?.removeSuffix("px")?.toFloatOrNull()
?: throw IllegalArgumentException("width can never be null")

val unit = spec["unit"] ?: spec["height"]?.takeLast(2) ?: spec["width"]?.takeLast(2)
val unitValue: Unit =
unit?.let { Unit.entries.find { it.value == unit } }
?: throw IllegalArgumentException("unit can never be null")

return Dimensions(
height = heightValue,
width = widthValue,
unit = unitValue
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package sergio.sastre.composable.preview.scanner.android.device.domain

import sergio.sastre.composable.preview.scanner.android.device.domain.Cutout.NONE
import sergio.sastre.composable.preview.scanner.android.device.domain.Navigation.GESTURE
import kotlin.math.ceil
import kotlin.math.floor

data class Device(
val identifier: Identifier? = null,
val dimensions: Dimensions,
val densityDpi: Int,
val orientation: Orientation,
val shape: Shape,
val chinSize: Int = 0,
val type: Type? = null,
val screenRatio: ScreenRatio = ScreenRatioCalculator.calculateFor(dimensions),
val screenSize: ScreenSize = ScreenSizeCalculator.calculateFor(dimensions, densityDpi),
val cutout: Cutout = NONE,
val navigation: Navigation = GESTURE,
) {

fun inDp(): Device = this.copy(dimensions = dimensions.inDp(densityDpi))

fun inPx(): Device = this.copy(dimensions = dimensions.inPx(densityDpi))
}

class Dimensions(
val height: Float,
val width: Float,
val unit: Unit,
) {
fun inDp(densityDpi: Int): Dimensions {
val conversionFactor: Float = densityDpi / 160f
return when (unit) {
Unit.DP -> this
Unit.PX -> Dimensions(
height = floor(height / conversionFactor),
width = floor(width / conversionFactor),
unit = Unit.DP
)
}
}

fun inPx(densityDpi: Int): Dimensions {
val conversionFactor: Float = densityDpi / 160f
return when (unit) {
Unit.PX -> this
Unit.DP ->
Dimensions(
height = ceil(height * conversionFactor),
width = ceil(width * conversionFactor),
unit = Unit.DP
)
}
}
}

enum class Unit(val value: String) {
DP("dp"),
PX("px")
}

enum class Shape { ROUND, NOTROUND }

enum class ScreenRatio { LONG, NOTLONG }

enum class ScreenSize { SMALL, NORMAL, LARGE, XLARGE }

enum class Orientation(val value: String) {
PORTRAIT("portrait"),
LANDSCAPE("landscape")
}

enum class Cutout(val value: String) {
NONE("none"),
CORNER("corner"),
DOUBLE("double"),
PUNCH_HOLE("punch_hole"),
TALL("tall")
}

enum class Navigation(val value: String) {
GESTURE("gesture"),
BUTTONS("buttons")
}

enum class Type { PHONE, TABLET, DESKTOP, FOLDABLE, WEAR, CAR, TV }
Loading

0 comments on commit 149ceaf

Please sign in to comment.