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  */
ComposeExecutionControlnull125 fun 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. */
ComposeExecutionControlnull133 fun ComposeExecutionControl.assertLastRecomposeHadChanges() {
134     assertLastRecomposeResult(expectingChanges = true)
135 }
136 
137 /** Asserts that last recomposition had no changes. */
ComposeExecutionControlnull138 fun 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  */
ComposeExecutionControlnull148 private 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  */
ComposeExecutionControlnull165 fun 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  */
ComposeExecutionControlnull175 fun 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  */
ComposeExecutionControlnull186 fun 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  */
ComposeExecutionControlnull201 fun 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
ComposeExecutionControlnull222 fun 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
ComposeExecutionControlnull238 fun 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