diff --git a/.github/ISSUE_TEMPLATE/testharness-bug-report.md b/.github/ISSUE_TEMPLATE/testharness-bug-report.md new file mode 100644 index 000000000..b597103af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/testharness-bug-report.md @@ -0,0 +1,16 @@ +--- +name: Test harness bug report +about: Create a report about test harness +title: "[Test Harness]" +labels: testharness +assignees: alexvanyo + +--- + +**Description** + +**Steps to reproduce** + +**Expected behavior** + +**Additional context** diff --git a/README.md b/README.md index e5dbb345d..2a9bab1cb 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ A wrapper around WebView for basic WebView support in Jetpack Compose. ### ๐Ÿ“œ [Adaptive](./adaptive/) A library providing a collection of utilities for adaptive layouts. +### ๐Ÿ“œ [Test Harness](./testharness/) +Utilities for testing Compose layouts. + ### ๐Ÿ“ [Insets](./insets/) (Deprecated) See our [Migration Guide](https://google.github.io/accompanist/insets/) for migrating to Insets in Compose. diff --git a/adaptive/build.gradle b/adaptive/build.gradle index 977a947e7..ad692d30f 100644 --- a/adaptive/build.gradle +++ b/adaptive/build.gradle @@ -96,6 +96,9 @@ dependencies { androidTestImplementation project(':internal-testutils') testImplementation project(':internal-testutils') + androidTestImplementation project(':testharness') + testImplementation project(':testharness') + androidTestImplementation libs.junit testImplementation libs.junit diff --git a/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt index 6419f47af..af51819c5 100644 --- a/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt +++ b/adaptive/src/sharedTest/kotlin/com/google/accompanist/adaptive/TwoPaneTest.kt @@ -19,8 +19,6 @@ package com.google.accompanist.adaptive import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -29,7 +27,6 @@ import androidx.compose.ui.graphics.toAndroidRect import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -46,6 +43,7 @@ import androidx.window.core.ExperimentalWindowApi import androidx.window.layout.DisplayFeature import androidx.window.layout.FoldingFeature import androidx.window.testing.layout.FoldingFeature +import com.google.accompanist.testharness.TestHarness import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test @@ -65,8 +63,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Ltr + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -88,7 +89,6 @@ class TwoPaneTest { splitFraction = 1f / 3f ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -102,7 +102,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -113,7 +113,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -125,8 +125,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Rtl + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -148,7 +151,6 @@ class TwoPaneTest { splitFraction = 1f / 3f ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -162,7 +164,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -173,7 +175,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -185,8 +187,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Ltr + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -209,7 +214,6 @@ class TwoPaneTest { gapWidth = 64.dp ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -223,7 +227,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -234,7 +238,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -246,8 +250,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Rtl + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -270,7 +277,6 @@ class TwoPaneTest { gapWidth = 64.dp ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -284,7 +290,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -295,7 +301,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -307,31 +313,34 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FractionVerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionVerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -342,7 +351,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -353,7 +362,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -365,32 +374,35 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FractionVerticalTwoPaneStrategy( - splitFraction = 1f / 3f, - gapHeight = 64.dp - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp), + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FractionVerticalTwoPaneStrategy( + splitFraction = 1f / 3f, + gapHeight = 64.dp + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -401,7 +413,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -412,7 +424,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -424,8 +436,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Ltr + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -448,7 +463,6 @@ class TwoPaneTest { offsetFromStart = true, ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -462,7 +476,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -473,7 +487,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -485,8 +499,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Rtl + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -509,7 +526,6 @@ class TwoPaneTest { offsetFromStart = true, ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -523,7 +539,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -534,7 +550,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -546,8 +562,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Ltr + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -571,7 +590,6 @@ class TwoPaneTest { gapWidth = 64.dp ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -585,7 +603,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -596,7 +614,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -608,8 +626,11 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + TestHarness( + size = DpSize(900.dp, 1200.dp), + layoutDirection = LayoutDirection.Rtl + ) { + density = LocalDensity.current TwoPane( first = { Spacer( @@ -633,7 +654,6 @@ class TwoPaneTest { gapWidth = 64.dp ), modifier = Modifier - .requiredSize(900.dp, 1200.dp) .onPlaced { twoPaneCoordinates = it } ) } @@ -647,7 +667,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -658,7 +678,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -670,32 +690,35 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FixedOffsetVerticalTwoPaneStrategy( - splitOffset = 300.dp, - offsetFromTop = true - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = true + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -706,7 +729,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -717,7 +740,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -729,33 +752,36 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FixedOffsetVerticalTwoPaneStrategy( - splitOffset = 300.dp, - offsetFromTop = true, - gapHeight = 64.dp - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = true, + gapHeight = 64.dp + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -766,7 +792,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -777,7 +803,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -789,32 +815,35 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FixedOffsetVerticalTwoPaneStrategy( - splitOffset = 300.dp, - offsetFromTop = false - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = false + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -825,7 +854,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -836,7 +865,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -848,33 +877,36 @@ class TwoPaneTest { lateinit var secondCoordinates: LayoutCoordinates composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = FixedOffsetVerticalTwoPaneStrategy( - splitOffset = 300.dp, - offsetFromTop = false, - gapHeight = 64.dp - ), - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = FixedOffsetVerticalTwoPaneStrategy( + splitOffset = 300.dp, + offsetFromTop = false, + gapHeight = 64.dp + ), + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -885,7 +917,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -896,7 +928,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -915,32 +947,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -951,7 +986,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -962,7 +997,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -988,32 +1023,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -1024,7 +1062,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1035,7 +1073,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -1061,32 +1099,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -1097,7 +1138,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1108,7 +1149,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -1134,32 +1175,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -1170,7 +1214,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1181,7 +1225,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -1207,32 +1251,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -1243,7 +1290,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1254,7 +1301,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -1280,32 +1327,35 @@ class TwoPaneTest { } composeTestRule.setContent { - density = LocalDensity.current - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } + ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } + ) + } } compareRectWithTolerance( @@ -1316,7 +1366,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1327,7 +1377,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } @@ -1337,49 +1387,52 @@ class TwoPaneTest { lateinit var twoPaneCoordinates: LayoutCoordinates lateinit var firstCoordinates: LayoutCoordinates lateinit var secondCoordinates: LayoutCoordinates + val displayFeatures = DelegateList { + fakeDisplayFeatures( + density = density, + twoPaneCoordinates = twoPaneCoordinates, + localFoldingFeatures = listOf( + LocalFoldingFeature( + center = 450.dp, + size = 0.dp, + state = FoldingFeature.State.FLAT, + orientation = FoldingFeature.Orientation.VERTICAL + ) + ) + ) + } composeTestRule.setContent { - density = LocalDensity.current - val displayFeatures = DelegateList { - fakeDisplayFeatures( - density = density, - twoPaneCoordinates = twoPaneCoordinates, - localFoldingFeatures = listOf( - LocalFoldingFeature( - center = 450.dp, - size = 0.dp, - state = FoldingFeature.State.FLAT, - orientation = FoldingFeature.Orientation.VERTICAL + TestHarness( + size = DpSize(900.dp, 1200.dp) + ) { + density = LocalDensity.current + + TwoPane( + first = { + Spacer( + Modifier + .background(Color.Red) + .fillMaxSize() + .onPlaced { firstCoordinates = it } ) - ) + }, + second = { + Spacer( + Modifier + .background(Color.Blue) + .fillMaxSize() + .onPlaced { secondCoordinates = it } + ) + }, + strategy = VerticalTwoPaneStrategy( + splitFraction = 1f / 3f + ), + displayFeatures = displayFeatures, + modifier = Modifier + .onPlaced { twoPaneCoordinates = it } ) } - - TwoPane( - first = { - Spacer( - Modifier - .background(Color.Red) - .fillMaxSize() - .onPlaced { firstCoordinates = it } - ) - }, - second = { - Spacer( - Modifier - .background(Color.Blue) - .fillMaxSize() - .onPlaced { secondCoordinates = it } - ) - }, - strategy = VerticalTwoPaneStrategy( - splitFraction = 1f / 3f - ), - displayFeatures = displayFeatures, - modifier = Modifier - .requiredSize(900.dp, 1200.dp) - .onPlaced { twoPaneCoordinates = it } - ) } compareRectWithTolerance( @@ -1390,7 +1443,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(firstCoordinates), - 0.001f + 1f ) compareRectWithTolerance( @@ -1401,7 +1454,7 @@ class TwoPaneTest { ).toRect().round().toRect() }, twoPaneCoordinates.localBoundingBoxOf(secondCoordinates), - 0.001f + 1f ) } } diff --git a/docs/testharness.md b/docs/testharness.md new file mode 100644 index 000000000..1fa9df162 --- /dev/null +++ b/docs/testharness.md @@ -0,0 +1,154 @@ +# Test Harness for Jetpack Compose + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-testharness)](https://search.maven.org/search?q=g:com.google.accompanist) + +A library providing a test harness for UI components. + +## Background + +Device configuration (locale, font size, screen size, folding features, etc.) are device-wide +properties, which makes it hard to automate tests that wants to vary these properties. +One current solution is to run tests across a range of emulators or devices with different +properties, and potentially filter tests to only run when specific conditions are met. +This has the downside of increasing the number of devices to manage, higher complexity of +configuring those devices, and more complicated test suites. + +With a Compose-only app, it is less common that the โ€œphysicalโ€ constraints of the device are +directly used. +Instead, state hoisting encourages isolating such constraints, and providing them to components via +state that is observable via snapshots. +The mechanism to do so is primarily via a set of composition locals, such as `LocalConfiguration`, +`LocalDensity`, and others. +The composition local mechanism provides a layer of indirection that permits overriding these +constraints via those composition local hooks. + +## Test Harness + +`TestHarness` is an `@Composable` function, which takes a single slot of `@Composable` content. +This content is the `@Composable` UI under test, so standard usage would look like the following: + +```kotlin +@Test +fun example() { + composeTestRule.setContent { + TestHarness(/* ... */) { + MyComponent() + } + } + + // assertions +} +``` + +When no parameters of `TestHarness` are specified, `TestHarness` has no direct effect, and it would +be equivalent to calling `MyComponent` directly. + +Specifying parameters of `TestHarness` results in overriding the default configuration for the +content under-test, and will affect `MyComponent`. + +For example, specifying the `fontScale` parameter will change the effective font scale within +the `TestHarness`: + +```kotlin +@Test +fun example() { + composeTestRule.setContent { + TestHarness(fontScale = 1.5f) { + Text("Configuration: ${LocalConfiguration.current.fontScale}") + Text("Density: ${LocalDensity.current.fontScale}") + } + } + + composeTestRule.onNodeWithText("Configuration: 1.5").assertExists() + composeTestRule.onNodeWithText("Density: 1.5").assertExists() +} +``` + +This allows testing UI for different font scales in a isolated way, without having to directly +configure the device to use a different font scale. + +`TestHarness` also takes a `size: DpSize` parameter, to test a Composable at a particular size. + +```kotlin +@Test +fun example() { + composeTestRule.setContent { + TestHarness(size = DpSize(800.dp, 1000.dp)) { + MyComponent() // will be rendered at 800dp by 1000dp, even if the window is smaller + } + } +} +``` + +See the full list of parameters and effects below. + +## Parameters + +The full list of parameters and their effects: + +| Parameter | Default value | Effect | +|-------------------------------------|-----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| `size: DpSize` | `DpSize.Unspecified` | If specified, overrides `LocalDensity` if needed to give the `DpSize` amount of space to the composable under test | +| `darkMode: Boolean` | `isSystemInDarkTheme()` | Overrides `LocalConfiguration.current.uiMode` | +| `fontScale: Float` | `LocalDensity.current.fontScale` | Overrides `LocalDensity.current.fontScale` and `LocalConfiguration.current.fontScale` | +| `fontWeightAdjustment: Int?` | `LocalConfiguration.current.fontWeightAdjustment` on API 31 and above, otherwise `null` | Overrides `LocalConfiguration.current.fontWeightAdjustment` on API 31 and above and not-null | +| `locales: LocaleListCompat` | `ConfigurationCompat.getLocales(LocalConfiguration.current)` | Overrides `LocalConfiguration.current.locales` | +| `layoutDirection: LayoutDirection?` | `null` (which uses the resulting locale layout direction) | Overrides `LocalLayoutDirection.current` and `LocalConfiguration.current.screenLayout` | + +## Implementation + +`TestHarness` works by overriding a set of composition locals provided to the content under test. + +The full list of composition locals that may be overridden by various parameters are: + +- `LocalConfiguration` +- `LocalContext` +- `LocalLayoutDirection` +- `LocalDensity` +- `LocalFontFamilyResolver` + +Any composable that depends on these composition locals should be testable via the test harness, +because they will pull the overridden configuration information from them. +This includes configuration-specific resources, because these are pulled from `LocalContext`. + +Testing a composable at a smaller size than the real screen space available is straightforward, but +testing a composable at a larger size than the real screen space available is not. This is because +the library and the testing APIs are sensitive to whether or not a composable is actually rendered +within the window of the application. + +As a solution, `TestHarness` will override the `LocalDensity` to shrink the content as necessary +for all of the specified `size: DpSize` to be displayed at once in the window space that is +available. This results in the composable under test believing it has the specified space to work +with, even if that is larger than the window of the application. + +## Limitations + +The test harness is simulating alternate configurations and sizes, so it does not exactly represent +what a user would see on a real device. +For that reason, the platform edges where Composables interact with the system more is where the +test harness may break down and have issues. +An incomplete list includes: dialogs (due to different `Window` instances), insets, soft keyboard +interactions, and interop with `View`s. +The density overriding when specifying a specific size to test a composable at also means that UI +might be rendered in atypical ways, especially at the extreme of rendering a very large desktop-size +UI on a small portrait phone. +The mechanism that the test harness uses is also not suitable for production code: in production, +the default configuration as specified by the user and the system should be used. + +The mechanism that the test harness uses to override the configuration (`ContextThemeWrapper`) is +currently not supported by layoutlib, meaning `TestHarness` will not work in Android Studio +previews or screenshot testing that uses layoutlib. + +## Download + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-testharness)](https://search.maven.org/search?q=g:com.google.accompanist) + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-testharness:" +} +``` \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d70757f7e..c45bc0bf9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,7 +84,7 @@ androidx-test-espressoWeb = "androidx.test.espresso:espresso-web:3.5.0-alpha07" junit = "junit:junit:4.13.2" truth = "com.google.truth:truth:1.1.2" -robolectric = "org.robolectric:robolectric:4.8" +robolectric = "org.robolectric:robolectric:4.9" affectedmoduledetector = "com.dropbox.affectedmoduledetector:affectedmoduledetector:0.1.2" diff --git a/mkdocs.yml b/mkdocs.yml index 023ecd066..db4496f58 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,9 @@ nav: - 'Adaptive': - 'Guide': adaptive.md - 'API': api/adaptive + - 'Test Harness': + - 'Guide': testharness.md + - 'API': api/testharness - 'Snapshots': using-snapshot-version.md - 'Contributing': contributing.md - 'Maintainers': diff --git a/pager/build.gradle b/pager/build.gradle index 889422fb5..94ee43e39 100644 --- a/pager/build.gradle +++ b/pager/build.gradle @@ -95,6 +95,9 @@ dependencies { androidTestImplementation project(':internal-testutils') testImplementation project(':internal-testutils') + androidTestImplementation project(':testharness') + testImplementation project(':testharness') + androidTestImplementation libs.junit testImplementation libs.junit diff --git a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseHorizontalPagerTest.kt b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseHorizontalPagerTest.kt index 62e64e479..e02916f39 100644 --- a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseHorizontalPagerTest.kt +++ b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseHorizontalPagerTest.kt @@ -24,11 +24,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertHeightIsAtLeast @@ -42,6 +40,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.height import androidx.compose.ui.unit.width +import com.google.accompanist.testharness.TestHarness /** * Contains [HorizontalPager] tests. This class is extended @@ -125,7 +124,7 @@ abstract class BaseHorizontalPagerTest( userScrollEnabled: Boolean, onPageComposed: (Int) -> Unit ) { - CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + TestHarness(layoutDirection = layoutDirection) { applierScope = rememberCoroutineScope() Box { diff --git a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseVerticalPagerTest.kt b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseVerticalPagerTest.kt index 79b538f28..e11a3ce36 100644 --- a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseVerticalPagerTest.kt +++ b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/BaseVerticalPagerTest.kt @@ -22,11 +22,9 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertHeightIsAtLeast @@ -40,6 +38,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.height import androidx.compose.ui.unit.width +import com.google.accompanist.testharness.TestHarness /** * Contains the [VerticalPager] tests. This class is extended @@ -105,7 +104,7 @@ abstract class BaseVerticalPagerTest( userScrollEnabled: Boolean, onPageComposed: (Int) -> Unit ) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + TestHarness(layoutDirection = LayoutDirection.Ltr) { applierScope = rememberCoroutineScope() Box { diff --git a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/TestUtils.kt b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/TestUtils.kt index 784884d31..4cde1c336 100644 --- a/pager/src/sharedTest/kotlin/com/google/accompanist/pager/TestUtils.kt +++ b/pager/src/sharedTest/kotlin/com/google/accompanist/pager/TestUtils.kt @@ -16,34 +16,17 @@ package com.google.accompanist.pager -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.test.SemanticsNodeInteraction -import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipe import androidx.compose.ui.test.swipeWithVelocity import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection import kotlin.math.absoluteValue import kotlin.math.hypot import kotlin.math.roundToLong import kotlin.random.Random -fun ComposeContentTestRule.setContent( - layoutDirection: LayoutDirection? = null, - composable: @Composable () -> Unit, -) { - setContent { - CompositionLocalProvider( - LocalLayoutDirection provides (layoutDirection ?: LocalLayoutDirection.current), - content = composable - ) - } -} - internal fun SemanticsNodeInteraction.swipeAcrossCenterWithVelocity( velocityPerSec: Dp, distancePercentageX: Float = 0f, diff --git a/sample/build.gradle b/sample/build.gradle index f46a1bfba..ec1c357e9 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -61,6 +61,9 @@ dependencies { implementation project(':flowlayout') implementation project(':systemuicontroller') implementation project(':swiperefresh') + implementation project(':testharness') // Don't use in production! Use the configurations below. + testImplementation project(':testharness') + androidTestImplementation project(':testharness') implementation project(':web') implementation libs.androidx.appcompat diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 98877ba6a..33d7f1cbe 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -443,6 +443,16 @@ + + + + + + + diff --git a/sample/src/main/java/com/google/accompanist/sample/testharness/TestHarnessSample.kt b/sample/src/main/java/com/google/accompanist/sample/testharness/TestHarnessSample.kt new file mode 100644 index 000000000..6046250a4 --- /dev/null +++ b/sample/src/main/java/com/google/accompanist/sample/testharness/TestHarnessSample.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.sample.testharness + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import com.google.accompanist.sample.AccompanistSampleTheme +import com.google.accompanist.sample.R +import com.google.accompanist.testharness.TestHarness +import java.util.Locale + +/** + * A visual sample for the TestHarness Composable. Note that it should not be used in production. + */ +class TestHarnessSample : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + Column( + modifier = Modifier + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TestHarnessScreen() + TestHarness(size = DpSize(100.dp, 100.dp)) { + TestHarnessScreen("with a set size") + } + TestHarness(darkMode = true) { + TestHarnessScreen("with darkMode enabled") + } + TestHarness(fontScale = 2f) { + TestHarnessScreen("with a big font scale") + } + TestHarness(layoutDirection = LayoutDirection.Rtl) { + TestHarnessScreen("in RTL") + } + TestHarness(locales = LocaleListCompat.create(Locale("ar"))) { + TestHarnessScreen("in Arabic") + } + } + } + } +} + +@Preview +@Composable +fun TestHarnessScreen(text: String = "") { + AccompanistSampleTheme { + Surface( + modifier = Modifier + .border(1.dp, Color.LightGray) + .height(100.dp) + .fillMaxWidth() + ) { + Text( + stringResource(R.string.this_is_content, text), + modifier = Modifier.padding(8.dp) + ) + } + } +} diff --git a/sample/src/main/res/values-ar/strings.xml b/sample/src/main/res/values-ar/strings.xml new file mode 100644 index 000000000..695a1d044 --- /dev/null +++ b/sample/src/main/res/values-ar/strings.xml @@ -0,0 +1,19 @@ + + + + ู‡ุฐุง ู…ุถู…ูˆู† \n%s + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml index d5747166e..6180b3d25 100644 --- a/sample/src/main/res/values/strings.xml +++ b/sample/src/main/res/values/strings.xml @@ -14,7 +14,7 @@ ~ limitations under the License. --> - + Accompanist Sample Insets: Basic @@ -68,4 +68,7 @@ Adaptive: TwoPane Horizontal Adaptive: TwoPane Vertical + Test Harness + This is content\n%s + diff --git a/settings.gradle b/settings.gradle index 8968085a8..b278e6967 100644 --- a/settings.gradle +++ b/settings.gradle @@ -43,4 +43,5 @@ include ':flowlayout' include ':systemuicontroller' include ':swiperefresh' include ':sample' +include ':testharness' include ':web' diff --git a/testharness/README.md b/testharness/README.md new file mode 100644 index 000000000..db01fd7f8 --- /dev/null +++ b/testharness/README.md @@ -0,0 +1,21 @@ +# Test Harness for Jetpack Compose + +[![Maven Central](https://img.shields.io/maven-central/v/com.google.accompanist/accompanist-testharness)](https://search.maven.org/search?q=g:com.google.accompanist) + +For more information, visit the documentation: https://google.github.io/accompanist/testharness + +## Download + +```groovy +repositories { + mavenCentral() +} + +dependencies { + implementation "com.google.accompanist:accompanist-testharness:" +} +``` + +Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. These are updated on every commit. + + [snap]: https://oss.sonatype.org/content/repositories/snapshots/com/google/accompanist/accompanist-testharness/ diff --git a/testharness/api/current.api b/testharness/api/current.api new file mode 100644 index 000000000..95671a17d --- /dev/null +++ b/testharness/api/current.api @@ -0,0 +1,9 @@ +// Signature format: 4.0 +package com.google.accompanist.testharness { + + public final class TestHarnessKt { + method @androidx.compose.runtime.Composable public static void TestHarness(optional long size, optional boolean darkMode, optional androidx.core.os.LocaleListCompat locales, optional androidx.compose.ui.unit.LayoutDirection? layoutDirection, optional float fontScale, optional Integer? fontWeightAdjustment, kotlin.jvm.functions.Function0 content); + } + +} + diff --git a/testharness/build.gradle b/testharness/build.gradle new file mode 100644 index 000000000..551a7c429 --- /dev/null +++ b/testharness/build.gradle @@ -0,0 +1,140 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'org.jetbrains.dokka' +} + +kotlin { + explicitApi() +} + +android { + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + // targetSdkVersion has no effect for libraries. This is only used for the test APK + targetSdkVersion 33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + buildTypes { + debug { + enableUnitTestCoverage = true + } + } + + buildFeatures { + buildConfig false + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion libs.versions.composeCompiler.get() + } + + lintOptions { + textReport true + textOutput 'stdout' + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks + checkReleaseBuilds false + } + + packagingOptions { + // Some of the META-INF files conflict with coroutines-test. Exclude them to enable + // our test APK to build (has no effect on our AARs) + excludes += "/META-INF/AL2.0" + excludes += "/META-INF/LGPL2.1" + } + + testCoverage { + jacocoVersion = "0.8.8" + } + + testOptions { + unitTests { + includeAndroidResources = true + } + unitTests.all { + useJUnit { + excludeCategories 'com.google.accompanist.internal.test.IgnoreOnRobolectric' + } + jacoco { + // Required for JaCoCo + Robolectric + // https://github.com/robolectric/robolectric/issues/2230 + includeNoLocationClasses = true + + // Required for JDK 11 with the above + // https://github.com/gradle/gradle/issues/5184#issuecomment-391982009 + excludes = ["jdk.internal.*"] + } + } + animationsDisabled true + } + + sourceSets { + test { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + androidTest { + java.srcDirs += 'src/sharedTest/kotlin' + res.srcDirs += 'src/sharedTest/res' + } + } +} + +dependencies { + implementation libs.compose.foundation.foundation + implementation libs.androidx.core + testImplementation libs.androidx.core + implementation libs.napier + implementation libs.kotlin.coroutines.android + + // ====================== + // Test dependencies + // ====================== + + androidTestImplementation project(':internal-testutils') + testImplementation project(':internal-testutils') + + androidTestImplementation libs.junit + testImplementation libs.junit + + androidTestImplementation libs.truth + testImplementation libs.truth + + androidTestImplementation libs.compose.ui.test.junit4 + testImplementation libs.compose.ui.test.junit4 + + androidTestImplementation libs.compose.ui.test.manifest + testImplementation libs.compose.ui.test.manifest + + androidTestImplementation libs.androidx.test.runner + testImplementation libs.androidx.test.runner + + testImplementation libs.robolectric +} + +apply plugin: "com.vanniktech.maven.publish" diff --git a/testharness/gradle.properties b/testharness/gradle.properties new file mode 100644 index 000000000..4e7df7dcf --- /dev/null +++ b/testharness/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=accompanist-testharness +POM_NAME=Accompanist Test Harness +POM_PACKAGING=aar diff --git a/testharness/src/androidTest/kotlin/org/robolectric/annotation/Config.kt b/testharness/src/androidTest/kotlin/org/robolectric/annotation/Config.kt new file mode 100644 index 000000000..ddeee9b27 --- /dev/null +++ b/testharness/src/androidTest/kotlin/org/robolectric/annotation/Config.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.robolectric.annotation + +// No-op annotation for instrumented tests to build +annotation class Config(val sdk: IntArray) diff --git a/testharness/src/main/AndroidManifest.xml b/testharness/src/main/AndroidManifest.xml new file mode 100644 index 000000000..958f77ac5 --- /dev/null +++ b/testharness/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/testharness/src/main/java/com/google/accompanist/testharness/TestHarness.kt b/testharness/src/main/java/com/google/accompanist/testharness/TestHarness.kt new file mode 100644 index 000000000..cdaa262d6 --- /dev/null +++ b/testharness/src/main/java/com/google/accompanist/testharness/TestHarness.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.testharness + +import android.content.res.Configuration +import android.os.Build +import android.os.LocaleList +import android.util.DisplayMetrics +import android.view.ContextThemeWrapper +import android.view.View +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.core.os.ConfigurationCompat +import androidx.core.os.LocaleListCompat +import kotlin.math.floor + +/** + * Render [content] in a [Box] within a harness, overriding various device configuration values to + * make testing easier. + * + * @param size if not [DpSize.Unspecified], the [content] will be forced to be drawn with at this + * size, overriding [LocalDensity] if necessary to ensure that there is enough space. This + * defaults to [DpSize.Unspecified]. + * + * @param darkMode if true, the content will be rendered with dark mode. This defaults to the + * current dark mode value as reported by [isSystemInDarkTheme]. + * + * @param locales the list of locales to render the app with. This defaults to the list of locales + * returned by [LocalConfiguration.current]. + * + * @param layoutDirection an overriding layout direction. This defaults to `null`, which means + * that the layout direction from the [locales] is used instead. + * + * @param fontScale the font scale to render text at. This defaults to the current + * [Density.fontScale]. + * + * @param fontWeightAdjustment the font weight adjustment for fonts. This defaults to the current + * [fontWeightAdjustment] (if any). If `null`, the [fontWeightAdjustment] will be left unchanged. + */ +@Composable +fun TestHarness( + size: DpSize = DpSize.Unspecified, + darkMode: Boolean = isSystemInDarkTheme(), + locales: LocaleListCompat = ConfigurationCompat.getLocales(LocalConfiguration.current), + layoutDirection: LayoutDirection? = null, + fontScale: Float = LocalDensity.current.fontScale, + fontWeightAdjustment: Int? = + if (Build.VERSION.SDK_INT >= 31) LocalConfiguration.current.fontWeightAdjustment else null, + content: @Composable () -> Unit +) { + // Use the DensityForcedSize content wrapper if specified + val sizeContentWrapper: @Composable (@Composable () -> Unit) -> Unit = + if (size == DpSize.Unspecified) { + { it() } + } else { + { DensityForcedSize(size, it) } + } + + // First override the density. Doing this first allows using the resulting density in the + // overridden configuration. + sizeContentWrapper { + // Second, override the configuration, with the current configuration modified by the + // given parameters. + OverriddenConfiguration( + configuration = Configuration().apply { + // Initialize from the current configuration + updateFrom(LocalConfiguration.current) + // Set dark mode directly + uiMode = uiMode and Configuration.UI_MODE_NIGHT_MASK.inv() or if (darkMode) { + Configuration.UI_MODE_NIGHT_YES + } else { + Configuration.UI_MODE_NIGHT_NO + } + // Update the locale list + if (Build.VERSION.SDK_INT >= 24) { + setLocales(LocaleList.forLanguageTags(locales.toLanguageTags())) + } else { + setLocale(locales[0]) + } + // Override densityDpi + densityDpi = + floor(LocalDensity.current.density * DisplayMetrics.DENSITY_DEFAULT).toInt() + // Override font scale + this.fontScale = fontScale + // Maybe override fontWeightAdjustment + if (Build.VERSION.SDK_INT >= 31 && fontWeightAdjustment != null) { + this.fontWeightAdjustment = fontWeightAdjustment + } + }, + ) { + // Finally, override the layout direction again if manually specified, potentially + // overriding the one from the locale. + CompositionLocalProvider( + LocalLayoutDirection provides (layoutDirection ?: LocalLayoutDirection.current) + ) { + content() + } + } + } +} + +/** + * Overrides the compositions locals related to the given [configuration]. + * + * There currently isn't a single source of truth for these values, so we update them all + * according to the given [configuration]. + */ +@Composable +internal fun OverriddenConfiguration( + configuration: Configuration, + content: @Composable () -> Unit +) { + // We don't override the theme, but we do want to override the configuration and this seems + // convenient to do so + val newContext = ContextThemeWrapper(LocalContext.current, 0).apply { + applyOverrideConfiguration(configuration) + } + + CompositionLocalProvider( + LocalContext provides newContext, + LocalConfiguration provides configuration, + LocalLayoutDirection provides + if (configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR) { + LayoutDirection.Ltr + } else { + LayoutDirection.Rtl + }, + LocalDensity provides Density( + configuration.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT, + configuration.fontScale + ), + LocalFontFamilyResolver provides createFontFamilyResolver(newContext), + content = content + ) +} + +/** + * Render [content] in a [Box] that is forced to have the given [size] without clipping. + * + * This is only suitable for tests, since this will override [LocalDensity] to ensure that the + * [size] is met (as opposed to [Modifier.requiredSize] which will result in clipping). + */ +@Composable +internal fun DensityForcedSize( + size: DpSize, + content: @Composable () -> Unit +) { + BoxWithConstraints( + // Try to set the size naturally, we'll be overriding the density below if this fails + modifier = Modifier.size(size) + ) { + // Compute the minimum density required so that both the requested width and height both + // fit + val density = LocalDensity.current.density * minOf( + maxWidth / maxOf(maxWidth, size.width), + maxHeight / maxOf(maxHeight, size.height), + ) + // Configuration requires the density DPI to be an integer, so round down to ensure we + // have enough space + val densityDpi = floor(density * DisplayMetrics.DENSITY_DEFAULT).toInt() + + CompositionLocalProvider( + LocalDensity provides Density( + // Override the density with the factor needed to meet both the minimum width and + // height requirements, and the configuration override requirements. + density = densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT, + // Pass through the font scale + fontScale = LocalDensity.current.fontScale + ) + ) { + Box( + // This size will now be guaranteed to be able to match the constraints + modifier = Modifier.size(size).fillMaxSize() + ) { + content() + } + } + } +} diff --git a/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/FakeTests.kt b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/FakeTests.kt new file mode 100644 index 000000000..24a948b79 --- /dev/null +++ b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/FakeTests.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.testharness + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Fake tests to help with sharding: https://github.com/android/android-test/issues/973 + */ +@RunWith(JUnit4::class) +class FakeTests { + @Test + fun fake1() = Unit + + @Test + fun fake2() = Unit + + @Test + fun fake3() = Unit + + @Test + fun fake4() = Unit + + @Test + fun fake5() = Unit + + @Test + fun fake6() = Unit + + @Test + fun fake7() = Unit + + @Test + fun fake8() = Unit + + @Test + fun fake9() = Unit + + @Test + fun fake10() = Unit +} diff --git a/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTest.kt b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTest.kt new file mode 100644 index 000000000..e7b2123be --- /dev/null +++ b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTest.kt @@ -0,0 +1,370 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.testharness + +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_MASK +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +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.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import com.google.accompanist.testharness.test.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +class TestHarnessTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun size_SmallerThanOuterBox_measuredWidthIsCorrect() { + var width = 0.dp + composeTestRule.setContent { + Box(Modifier.requiredSize(300.dp)) { + TestHarness(size = DpSize(200.dp, 200.dp)) { + BoxOfSize(200.dp, onWidth = { width = it }) + } + } + } + composeTestRule.waitForIdle() + + val ratio = width / 200.dp + assertEquals(ratio, 1f, 0.01f) + } + + @Test + fun size_BiggerThanOuterBox_measuredWidthIsCorrect() { + var width = 0.dp + composeTestRule.setContent { + Box(Modifier.requiredSize(100.dp)) { + TestHarness(size = DpSize(200.dp, 200.dp)) { + BoxOfSize(200.dp, onWidth = { width = it }) + } + } + } + composeTestRule.waitForIdle() + + val ratio = width / 200.dp + assertEquals(ratio, 1f, 0.01f) + } + + @Test + fun size_ExtremelyBig_measuredWidthIsCorrect() { + var width = 0.dp + composeTestRule.setContent { + TestHarness(size = DpSize(10000.dp, 10000.dp)) { + BoxOfSize(10000.dp, onWidth = { width = it }) + } + } + composeTestRule.waitForIdle() + + val ratio = width / 10000.dp + assertEquals(ratio, 1f, 0.01f) + } + + @Test + fun darkMode_enabled() { + var darkMode: Int = -1 + composeTestRule.setContent { + TestHarness(darkMode = true) { + darkMode = LocalConfiguration.current.uiMode + } + } + composeTestRule.waitForIdle() + + assertEquals(darkMode and UI_MODE_NIGHT_MASK, UI_MODE_NIGHT_YES) + } + + @Test + fun darkMode_disabled() { + var darkMode: Int = -1 + composeTestRule.setContent { + TestHarness(darkMode = false) { + darkMode = LocalConfiguration.current.uiMode + } + } + composeTestRule.waitForIdle() + + assertEquals(darkMode and UI_MODE_NIGHT_MASK, UI_MODE_NIGHT_NO) + } + + @Test + @SdkSuppress(minSdkVersion = 24) + fun locales_api24_allLocalesApplied() { + val expectedLocales = LocaleListCompat.create(Locale.CANADA, Locale.ITALY) + lateinit var locales: LocaleListCompat + composeTestRule.setContent { + TestHarness(locales = expectedLocales) { + locales = LocaleListCompat.wrap(LocalConfiguration.current.locales) + } + } + + composeTestRule.waitForIdle() + + // All locales are expected in Sdk>=24 + assertEquals(expectedLocales, locales) + } + + @Test + fun usLocale_usesCorrectResource() { + composeTestRule.setContent { + TestHarness(locales = LocaleListCompat.forLanguageTags("us")) { + BasicText(text = stringResource(R.string.this_is_content, "abc")) + } + } + composeTestRule.onNodeWithText("This is content\nabc").assertExists() + } + + @Test + fun arLocale_usesCorrectResource() { + composeTestRule.setContent { + TestHarness(locales = LocaleListCompat.forLanguageTags("ar")) { + BasicText(text = stringResource(R.string.this_is_content, "abc")) + } + } + composeTestRule.onNodeWithText("ู‡ุฐุง ู…ุถู…ูˆู† \nabc").assertExists() + } + + @Test + fun layoutDirection_RtlLocale_usesOverride() { + lateinit var direction: LayoutDirection + val initialLocale = LocaleListCompat.create(Locale("ar")) // Arabic + val initialLayoutDirection = LayoutDirection.Ltr + + // Given test harness setting an RTL Locale but it also forcing the opposite + // layout direction + composeTestRule.setContent { + TestHarness( + layoutDirection = initialLayoutDirection, + locales = initialLocale + ) { + direction = LocalLayoutDirection.current + } + } + composeTestRule.waitForIdle() + + // The used locale should be the one overriden with the test harness, ignoring the Locale's. + assertEquals(initialLayoutDirection, direction) + } + + @Test + fun layoutDirection_default_RtlLocale() { + lateinit var direction: LayoutDirection + val initialLocale = LocaleListCompat.create(Locale("ar")) // Arabic + + // Given an initial layout direction, when the test harness sets an RTL Locale and doesn't + // force the layout direction + composeTestRule.setContent { + TestHarness( + layoutDirection = null, + locales = initialLocale + ) { + direction = LocalLayoutDirection.current + } + } + composeTestRule.waitForIdle() + + // The used locale should be the Locale's. + assertEquals(LayoutDirection.Rtl, direction) + } + + @Test + fun layoutDirection_default_usesLocales() { + lateinit var direction: LayoutDirection + val initialLocale = LocaleListCompat.create(Locale("ar")) // Arabic + val initialLayoutDirection = LayoutDirection.Ltr + + // Given no layout direction, when the test harness sets an RTL Locale with an initial + // LTR direction + composeTestRule.setContent { + CompositionLocalProvider( + LocalLayoutDirection provides initialLayoutDirection + ) { + TestHarness( + layoutDirection = null, + locales = initialLocale + ) { + direction = LocalLayoutDirection.current + } + } + } + composeTestRule.waitForIdle() + + // The default should be the one provided by the Locale + assertNotEquals(initialLayoutDirection, direction) + } + + @Test + fun layoutDirection_setLtr() { + lateinit var direction: LayoutDirection + val initialLayoutDirection = LayoutDirection.Rtl + val expected = LayoutDirection.Ltr + + // Given a content with an initial RTL layout direction, when the test harness overrides it + composeTestRule.setContent { + CompositionLocalProvider( + LocalLayoutDirection provides initialLayoutDirection + ) { + TestHarness(layoutDirection = expected) { + direction = LocalLayoutDirection.current + } + } + } + composeTestRule.waitForIdle() + + // The direction should be the one forced by the test harness + assertEquals(expected, direction) + } + + @Test + fun layoutDirection_setRtl() { + lateinit var direction: LayoutDirection + val initialLayoutDirection = LayoutDirection.Ltr + val expected = LayoutDirection.Rtl + + // Given a content with an initial RTL layout direction, when the test harness overrides it + composeTestRule.setContent { + CompositionLocalProvider( + LocalLayoutDirection provides initialLayoutDirection + ) { + TestHarness(layoutDirection = expected) { + direction = LocalLayoutDirection.current + } + } + } + composeTestRule.waitForIdle() + + // The direction should be the one forced by the test harness + assertEquals(expected, direction) + } + + @Test + fun layoutDirection_default_followsLocaleLtr() { + lateinit var direction: LayoutDirection + + // Given an initial layout direction and no overrides + composeTestRule.setContent { + CompositionLocalProvider( + LocalConfiguration provides Configuration().apply { + setLocale(Locale.ENGLISH) + } + ) { + TestHarness(layoutDirection = null) { + direction = LocalLayoutDirection.current + } + } + } + composeTestRule.waitForIdle() + + // The direction should be set by the Locale + assertEquals(LayoutDirection.Ltr, direction) + } + + @Test + fun layoutDirection_default_followsLocaleRtl() { + lateinit var direction: LayoutDirection + + // Given an initial layout direction and no overrides + composeTestRule.setContent { + CompositionLocalProvider( + LocalConfiguration provides Configuration().apply { + setLocale(Locale("ar")) + } + ) { + TestHarness(layoutDirection = null) { + direction = LocalLayoutDirection.current + } + } + } + composeTestRule.waitForIdle() + + // The direction should be set by the Locale + assertEquals(LayoutDirection.Rtl, direction) + } + + @Test + fun fontScale() { + val expectedFontScale = 5f + var fontScale = 0f + // Given + composeTestRule.setContent { + TestHarness(fontScale = expectedFontScale) { + fontScale = LocalConfiguration.current.fontScale + } + } + + composeTestRule.waitForIdle() + + assertEquals(expectedFontScale, fontScale) + } + + @Test + @SdkSuppress(minSdkVersion = 31) + fun fontWeightAdjustment() { + val expectedFontWeightAdjustment = 10 + var fontWeightAdjustment = 0 + composeTestRule.setContent { + TestHarness(fontWeightAdjustment = expectedFontWeightAdjustment) { + fontWeightAdjustment = LocalConfiguration.current.fontWeightAdjustment + } + } + + composeTestRule.waitForIdle() + + assertEquals(expectedFontWeightAdjustment, fontWeightAdjustment) + } + + @Composable + private fun BoxOfSize(size: Dp, onWidth: (Dp) -> Unit) { + val localDensity = LocalDensity.current + Box( + Modifier + .size(size) + .background(color = Color.Black) + .onGloballyPositioned { it: LayoutCoordinates -> + onWidth(with(localDensity) { it.size.width.toDp() }) + } + ) + } +} diff --git a/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTestApi23.kt b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTestApi23.kt new file mode 100644 index 000000000..2ebffce10 --- /dev/null +++ b/testharness/src/sharedTest/kotlin/com/google/accompanist/testharness/TestHarnessTestApi23.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.accompanist.testharness + +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.core.os.LocaleListCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [23]) +class TestHarnessTestApi23 { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + @SdkSuppress(maxSdkVersion = 23) + fun locales_api23_onlyfirstLocaleApplied() { + val expectedLocales = LocaleListCompat.create(Locale.CANADA, Locale.ITALY) + lateinit var locales: LocaleListCompat + composeTestRule.setContent { + TestHarness(locales = expectedLocales) { + @Suppress("DEPRECATION") + locales = LocaleListCompat.create(LocalConfiguration.current.locale) + } + } + + composeTestRule.waitForIdle() + + // Only one of the locales is used in Sdk<24 + assertEquals(LocaleListCompat.create(Locale.CANADA), locales) + } +} diff --git a/testharness/src/sharedTest/res/values-ar/strings.xml b/testharness/src/sharedTest/res/values-ar/strings.xml new file mode 100644 index 000000000..695a1d044 --- /dev/null +++ b/testharness/src/sharedTest/res/values-ar/strings.xml @@ -0,0 +1,19 @@ + + + + ู‡ุฐุง ู…ุถู…ูˆู† \n%s + diff --git a/testharness/src/sharedTest/res/values/strings.xml b/testharness/src/sharedTest/res/values/strings.xml new file mode 100644 index 000000000..04e487adb --- /dev/null +++ b/testharness/src/sharedTest/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + This is content\n%s + diff --git a/testharness/src/test/resources/robolectric.properties b/testharness/src/test/resources/robolectric.properties new file mode 100644 index 000000000..d472dcb0b --- /dev/null +++ b/testharness/src/test/resources/robolectric.properties @@ -0,0 +1,3 @@ +# Pin SDK to 30 since Robolectric does not currently support API 31: +# https://github.com/robolectric/robolectric/issues/6635 +sdk=33