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