1 /*
2  * Copyright 2020 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.runtime.mock
18 
19 import androidx.compose.runtime.Composable
20 import androidx.compose.runtime.Composition
21 import androidx.compose.runtime.ControlledComposition
22 import androidx.compose.runtime.ExperimentalComposeRuntimeApi
23 import androidx.compose.runtime.InternalComposeApi
24 import androidx.compose.runtime.Recomposer
25 import androidx.compose.runtime.snapshots.Snapshot
26 import androidx.compose.runtime.tooling.CompositionObserver
27 import androidx.compose.runtime.tooling.CompositionObserverHandle
28 import androidx.compose.runtime.tooling.observe
29 import kotlin.test.assertFalse
30 import kotlin.test.assertTrue
31 import kotlinx.coroutines.CoroutineScope
32 import kotlinx.coroutines.ExperimentalCoroutinesApi
33 import kotlinx.coroutines.launch
34 import kotlinx.coroutines.test.TestCoroutineScheduler
35 import kotlinx.coroutines.test.runTest
36 import kotlinx.coroutines.withContext
37 
38 @OptIn(InternalComposeApi::class, ExperimentalCoroutinesApi::class)
compositionTestnull39 fun compositionTest(
40     block: suspend CompositionTestScope.() -> Unit,
41 ) = runTest {
42     withContext(TestMonotonicFrameClock(this)) {
43         // Start the recomposer
44         val recomposer = Recomposer(coroutineContext)
45         launch { recomposer.runRecomposeAndApplyChanges() }
46         testScheduler.runCurrent()
47 
48         // Create a test scope for the test using the test scope passed in by runTest
49         val scope =
50             object : CompositionTestScope, CoroutineScope by this@runTest {
51                 var composed = false
52                 override var composition: Composition? = null
53 
54                 override lateinit var root: View
55 
56                 override val testCoroutineScheduler: TestCoroutineScheduler
57                     get() = this@runTest.testScheduler
58 
59                 override fun compose(block: @Composable () -> Unit) {
60                     check(!composed) { "Compose should only be called once" }
61                     composed = true
62                     root = View().apply { name = "root" }
63                     val composition = Composition(ViewApplier(root), recomposer)
64                     this.composition = composition
65                     composition.setContent(block)
66                 }
67 
68                 @OptIn(ExperimentalComposeRuntimeApi::class)
69                 override fun compose(
70                     observer: CompositionObserver,
71                     block: @Composable () -> Unit
72                 ): CompositionObserverHandle? {
73                     check(!composed) { "Compose should only be called once" }
74                     composed = true
75                     root = View().apply { name = "root" }
76                     val composition = Composition(ViewApplier(root), recomposer)
77                     val result = composition.observe(observer)
78                     this.composition = composition
79                     composition.setContent(block)
80                     return result
81                 }
82 
83                 override fun advanceCount(ignorePendingWork: Boolean): Long {
84                     val changeCount = recomposer.changeCount
85                     Snapshot.sendApplyNotifications()
86                     if (recomposer.hasPendingWork) {
87                         advanceTimeBy(5_000)
88                         check(ignorePendingWork || !recomposer.hasPendingWork) {
89                             "Potentially infinite recomposition, still recomposing after advancing"
90                         }
91                     }
92                     return recomposer.changeCount - changeCount
93                 }
94 
95                 override fun advanceTimeBy(amount: Long) = testScheduler.advanceTimeBy(amount)
96 
97                 override fun advance(ignorePendingWork: Boolean) =
98                     advanceCount(ignorePendingWork) != 0L
99 
100                 override fun verifyConsistent() {
101                     (composition as? ControlledComposition)?.verifyConsistent()
102                 }
103 
104                 override var validator: (MockViewValidator.() -> Unit)? = null
105             }
106         scope.block()
107 
108         try {
109             scope.composition?.dispose()
110         } catch (_: Throwable) {
111             // suppress
112         } finally {
113             scope.composition = null
114             recomposer.cancel()
115             recomposer.join()
116         }
117     }
118 }
119 
120 /** A test scope used in tests that allows controlling and testing composition. */
121 @OptIn(ExperimentalCoroutinesApi::class)
122 interface CompositionTestScope : CoroutineScope {
123 
124     /** A scheduler used by [CoroutineScope] */
125     val testCoroutineScheduler: TestCoroutineScheduler
126 
127     /** Compose a block using the mock view composer. */
composenull128     fun compose(block: @Composable () -> Unit)
129 
130     /** Compose a block observed using the mock view composer. */
131     @OptIn(ExperimentalComposeRuntimeApi::class)
132     fun compose(
133         observer: CompositionObserver,
134         block: @Composable () -> Unit
135     ): CompositionObserverHandle?
136 
137     /**
138      * Advance the state which executes any pending compositions, if any. Returns true if advancing
139      * resulted in changes being applied.
140      */
141     fun advance(ignorePendingWork: Boolean = false): Boolean
142 
143     /** Advance counting the number of time the recomposer ran. */
144     fun advanceCount(ignorePendingWork: Boolean = false): Long
145 
146     /** Advance the clock by [amount] ms */
147     fun advanceTimeBy(amount: Long)
148 
149     /** Verify the composition is well-formed. */
150     fun verifyConsistent()
151 
152     /** The root mock view of the mock views being composed. */
153     val root: View
154 
155     /** The last validator used. */
156     var validator: (MockViewValidator.() -> Unit)?
157 
158     /** Access to the composition created for the call to [compose] */
159     var composition: Composition?
160 }
161 
162 /** Create a mock view validator and validate the view. */
163 fun CompositionTestScope.validate(block: MockViewValidator.() -> Unit) =
164     MockViewListValidator(root.children).validate(block).also { validator = block }
165 
166 /** Revalidate using the last validator */
revalidatenull167 fun CompositionTestScope.revalidate() = validate(validator ?: error("validate was not called"))
168 
169 /** Advance and expect changes */
170 fun CompositionTestScope.expectChanges() {
171     val changes = advance()
172     assertTrue(actual = changes, message = "Expected changes but none were found")
173 }
174 
175 /** Advance and expect no changes */
expectNoChangesnull176 fun CompositionTestScope.expectNoChanges() {
177     val changes = advance()
178     assertFalse(actual = changes, message = "Expected no changes but changes occurred")
179 }
180