1 /* 2 * Copyright 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.compose.testutils 18 19 import androidx.annotation.UiThread 20 21 expect class NativeView 22 23 /** Test scope accessible from execution controlled tests to test compose. */ 24 @UiThread 25 interface ComposeExecutionControl { 26 /** The measured width of the underlying view. */ 27 val measuredWidth: Int 28 29 /** The measured height of the underlying view. */ 30 val measuredHeight: Int 31 32 /** 33 * Performs measure. 34 * 35 * Note that this does not do any invalidation. 36 */ measurenull37 fun measure() 38 39 /** 40 * Performs layout. 41 * 42 * Note that this does not do any invalidation. 43 */ 44 fun layout() 45 46 /** 47 * Performs full draw. 48 * 49 * Note that the performance is not close to real draw (unless running Q+). 50 */ 51 fun drawToBitmap() 52 53 /** 54 * To be used for tests debugging. 55 * 56 * Draws the view under test into image view and places it in the current Activity. That will 57 * also replace the current content under test. This can be useful to verify / preview results 58 * of your time controlled tests. 59 */ 60 fun capturePreviewPictureToActivity() 61 62 /** Whether the last frame / recompose had changes to recompose. */ 63 val didLastRecomposeHaveChanges: Boolean 64 65 /** 66 * Performs the full frame. 67 * 68 * This also sets up the content in case the content was not set up before. 69 * 70 * Following steps are performed 71 * 1) Recompose 72 * 2) Measure 73 * 3) Layout 74 * 4) Draw 75 */ 76 fun doFrame() 77 78 /** Whether there are pending changes in the composition. */ 79 fun hasPendingChanges(): Boolean 80 81 /** Whether there are pending layout changes. */ 82 fun hasPendingMeasureOrLayout(): Boolean 83 84 /** Whether there are pending draw changes. */ 85 fun hasPendingDraw(): Boolean = false 86 87 /** 88 * Performs recomposition if needed. 89 * 90 * Note this is also called as part of [doFrame] 91 */ 92 fun recompose() 93 94 /** 95 * Please avoid using this API; Make your tests more platform agnostic by utilizing platform- 96 * independent test hooks instead of invoking APIs on native views. This API may be removed in 97 * the future. 98 */ 99 fun getHostView(): NativeView 100 101 /** A count on launched jobs in the composition. */ 102 fun getCoroutineLaunchedCount(): Int 103 } 104 105 /** Helper interface to run execution-controlled test via [ComposeTestRule]. */ 106 interface ComposeTestCaseSetup { 107 /** 108 * Takes the content provided via [ComposeTestRule#setContent] and runs the given test 109 * instruction. The test is executed on the main thread and prevents interference from Activity 110 * so the frames can be controlled manually. See [ComposeExecutionControl] for available 111 * methods. 112 */ 113 fun performTestWithEventsControl(block: ComposeExecutionControl.() -> Unit) 114 } 115 116 // Assertions 117 118 /** 119 * Assert that the underlying view under test has a positive size. 120 * 121 * Useful to assert that the test case has some content. 122 * 123 * @throws AssertionError if the underlying view has zero measured size. 124 */ ComposeExecutionControlnull125fun ComposeExecutionControl.assertMeasureSizeIsPositive() { 126 if (measuredWidth > 0 && measuredHeight > 0) { 127 return 128 } 129 throw AssertionError("Measured size is not positive!") 130 } 131 132 /** Asserts that last recomposition had some changes. */ ComposeExecutionControlnull133fun ComposeExecutionControl.assertLastRecomposeHadChanges() { 134 assertLastRecomposeResult(expectingChanges = true) 135 } 136 137 /** Asserts that last recomposition had no changes. */ ComposeExecutionControlnull138fun ComposeExecutionControl.assertLastRecomposeHadNoChanges() { 139 assertLastRecomposeResult(expectingChanges = false) 140 } 141 142 /** 143 * Performs recomposition and asserts that there were or weren't pending changes based on 144 * [expectingChanges]. 145 * 146 * @throws AssertionError if condition not satisfied. 147 */ ComposeExecutionControlnull148private fun ComposeExecutionControl.assertLastRecomposeResult(expectingChanges: Boolean) { 149 val message = 150 if (expectingChanges) { 151 "Expected pending changes on recomposition but there were none." 152 } else { 153 "Expected no pending changes on recomposition but there were some." 154 } 155 if (expectingChanges != didLastRecomposeHaveChanges) { 156 throw AssertionError(message) 157 } 158 } 159 160 /** 161 * Performs recomposition and asserts that there were some pending changes. 162 * 163 * @throws AssertionError if last recomposition had no changes. 164 */ ComposeExecutionControlnull165fun ComposeExecutionControl.recomposeAssertHadChanges() { 166 recompose() 167 assertLastRecomposeHadChanges() 168 } 169 170 /** 171 * Performs recomposition and asserts that there were no pending changes. 172 * 173 * @throws AssertionError if recomposition has pending changes. 174 */ ComposeExecutionControlnull175fun ComposeExecutionControl.assertNoPendingChanges() { 176 if (hasPendingChanges()) { 177 throw AssertionError("Expected no pending changes but there were some.") 178 } 179 } 180 181 /** 182 * Performs recomposition and asserts that there were some pending changes. 183 * 184 * @throws AssertionError if recomposition has no pending changes. 185 */ ComposeExecutionControlnull186fun ComposeExecutionControl.assertHasPendingChanges() { 187 if (!hasPendingChanges()) { 188 throw AssertionError("Expected pending changes but there were none.") 189 } 190 } 191 192 // Assertions runners 193 194 /** 195 * Performs the given amount of frames and asserts that there are no changes pending afterwards. 196 * Also asserts that all the frames (except the last one) had changes to recompose. 197 * 198 * @throws AssertionError if any frame before [numberOfFramesToBeStable] frame had no pending 199 * changes or the last frame had pending changes. 200 */ ComposeExecutionControlnull201fun ComposeExecutionControl.doFramesAssertAllHadChangesExceptLastOne( 202 numberOfFramesToBeStable: Int 203 ) { 204 val framesDone = doFramesUntilNoChangesPending(numberOfFramesToBeStable) 205 206 if (framesDone < numberOfFramesToBeStable) { 207 throw AssertionError( 208 "Hierarchy got stable in frame '$framesDone', which is before expected!" 209 ) 210 } 211 } 212 213 // Runners 214 215 /** 216 * Runs frames until there are no changes pending. 217 * 218 * @param maxAmountOfFrames Max amount of frames to perform before giving up and throwing exception. 219 * @throws AssertionError if there are still pending changes after [maxAmountOfFrames] executed. 220 */ 221 @UiThread ComposeExecutionControlnull222fun ComposeExecutionControl.doFramesUntilNoChangesPending(maxAmountOfFrames: Int = 10): Int { 223 var framesDone = 0 224 while (framesDone < maxAmountOfFrames) { 225 doFrame() 226 framesDone++ 227 if (!hasPendingChanges()) { 228 // We are stable! 229 return framesDone 230 } 231 } 232 233 // Still not stable 234 throw AssertionError("Changes are still pending after '$maxAmountOfFrames' " + "frames.") 235 } 236 237 @UiThread ComposeExecutionControlnull238fun ComposeExecutionControl.assertCoroutinesCount(expectedCount: Int) { 239 val actual = getCoroutineLaunchedCount() 240 if (getCoroutineLaunchedCount() != expectedCount) { 241 throw AssertionError("Coroutines launched is $actual when $expectedCount were expected.") 242 } 243 } 244