1 /*
<lambda>null2 * Copyright 2025 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.foundation.text.contextmenu.provider
18
19 import android.annotation.SuppressLint
20 import androidx.compose.foundation.background
21 import androidx.compose.foundation.clickable
22 import androidx.compose.foundation.layout.Arrangement
23 import androidx.compose.foundation.layout.Box
24 import androidx.compose.foundation.layout.Column
25 import androidx.compose.foundation.layout.fillMaxSize
26 import androidx.compose.foundation.layout.offset
27 import androidx.compose.foundation.layout.padding
28 import androidx.compose.foundation.layout.size
29 import androidx.compose.foundation.layout.sizeIn
30 import androidx.compose.foundation.text.BasicText
31 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuData
32 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItem
33 import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession
34 import androidx.compose.foundation.text.test.assertThatJob
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.compositionLocalOf
37 import androidx.compose.runtime.getValue
38 import androidx.compose.runtime.mutableIntStateOf
39 import androidx.compose.runtime.mutableStateOf
40 import androidx.compose.runtime.rememberCoroutineScope
41 import androidx.compose.runtime.setValue
42 import androidx.compose.ui.Alignment
43 import androidx.compose.ui.Modifier
44 import androidx.compose.ui.geometry.Offset
45 import androidx.compose.ui.geometry.Rect
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.layout.LayoutCoordinates
48 import androidx.compose.ui.layout.boundsInRoot
49 import androidx.compose.ui.platform.testTag
50 import androidx.compose.ui.test.assertIsDisplayed
51 import androidx.compose.ui.test.junit4.createComposeRule
52 import androidx.compose.ui.test.onNodeWithTag
53 import androidx.compose.ui.unit.IntOffset
54 import androidx.compose.ui.unit.dp
55 import androidx.compose.ui.unit.roundToIntRect
56 import androidx.compose.ui.unit.toIntRect
57 import androidx.compose.ui.unit.toOffset
58 import androidx.compose.ui.util.fastForEach
59 import com.google.common.truth.Truth.assertThat
60 import kotlin.test.assertFailsWith
61 import kotlinx.coroutines.CancellationException
62 import kotlinx.coroutines.CoroutineScope
63 import kotlinx.coroutines.Job
64 import kotlinx.coroutines.launch
65 import org.junit.Rule
66 import org.junit.Test
67
68 class BasicTextContextMenuProviderTest {
69 @get:Rule val rule = createComposeRule()
70
71 @Test
72 fun whenDefault_expectedItemsAppear() = runProviderTest {
73 showTextContextMenu(testDataProvider(1, 2))
74 assertContextMenuExistsWithNumbers(1, 2)
75 }
76
77 @Test
78 fun whenSessionCloseCalled_contextMenuDisappears() = runProviderTest {
79 val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
80 assertContextMenuExistsWithNumbers(1)
81 assertThatJob(contextMenuCoroutine).isActive()
82
83 assertNotNull(session).close()
84 rule.waitForIdle()
85
86 assertContextMenuDoesNotExist()
87 assertThatJob(contextMenuCoroutine).isCompleted()
88 }
89
90 @Test
91 fun whenCoroutineCancelled_contextMenuDisappears() = runProviderTest {
92 val contextMenuCoroutine = launch {
93 assertFailsWith<CancellationException> {
94 assertNotNull(provider).showTextContextMenu(testDataProvider(1))
95 }
96 }
97 assertContextMenuExistsWithNumbers(1)
98 assertThatJob(contextMenuCoroutine).isActive()
99
100 contextMenuCoroutine.cancel()
101 rule.waitForIdle()
102
103 assertContextMenuDoesNotExist()
104 assertThatJob(contextMenuCoroutine).isCancelled()
105 }
106
107 @Test
108 fun whenCallingShowTwice_contextMenuIsReplaced() = runProviderTest {
109 val firstContextMenuCoroutine = showTextContextMenu(testDataProvider(1))
110 assertContextMenuExistsWithNumbers(1)
111 assertContextMenuItemsWithNumbersDoNotExist(2)
112 assertThatJob(firstContextMenuCoroutine).isActive()
113
114 val secondContextMenuCoroutine = showTextContextMenu(testDataProvider(2))
115 assertContextMenuItemsWithNumbersDoNotExist(1)
116 assertContextMenuExistsWithNumbers(2)
117 assertThatJob(firstContextMenuCoroutine).isCompleted()
118 assertThatJob(secondContextMenuCoroutine).isActive()
119 }
120
121 @Test
122 fun whenRemovingAnchorLayout_contextMenuIsClosed() {
123 var showAnchorLayout by mutableStateOf(true)
124 runProviderTest(
125 outerContent = { content -> OuterBox { if (showAnchorLayout) content() } },
126 ) {
127 rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
128
129 val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
130
131 rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
132 assertContextMenuExistsWithNumbers(1)
133 assertThatJob(contextMenuCoroutine).isActive()
134
135 showAnchorLayout = false
136 rule.waitForIdle()
137
138 rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
139 assertContextMenuDoesNotExist()
140 assertThatJob(contextMenuCoroutine).isCompleted()
141 }
142 }
143
144 @Test
145 fun whenRemovingProvider_contextMenuIsClosed() = runProviderTest {
146 rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
147
148 val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
149
150 rule.onNodeWithTag(AnchorLayoutTag).assertIsDisplayed()
151 assertContextMenuExistsWithNumbers(1)
152 assertThatJob(contextMenuCoroutine).isActive()
153
154 enabled = false
155 rule.waitForIdle()
156
157 rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
158 assertContextMenuDoesNotExist()
159 assertThatJob(contextMenuCoroutine).isCompleted()
160 }
161
162 @Test
163 fun whenShowingThenInstantlyClosing_coroutineDoesNotHang() = runProviderTest {
164 val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
165 enabled = false
166 rule.waitForIdle()
167
168 rule.onNodeWithTag(AnchorLayoutTag).assertDoesNotExist()
169 assertContextMenuDoesNotExist()
170 assertThatJob(contextMenuCoroutine).isCompleted()
171 }
172
173 @Test
174 fun whenMovingAnchorLayout_contextMenuReceivesUpdate() {
175 var length by mutableIntStateOf(0)
176 runProviderTest(
177 outerContent = { content ->
178 OuterBox(Modifier.offset { IntOffset(length, length) }.size(150.dp)) { content() }
179 },
180 ) {
181 val contextMenuCoroutine = showTextContextMenu(testDataProvider(1))
182
183 assertContextMenuExistsWithNumbers(1)
184 assertThatJob(contextMenuCoroutine).isActive()
185 val initialBounds = anchorLayoutCoordinates.boundsInRoot().roundToIntRect()
186
187 length = 50
188 rule.waitForIdle()
189
190 assertContextMenuExistsWithNumbers(1)
191 assertThatJob(contextMenuCoroutine).isActive()
192
193 val finalBounds = anchorLayoutCoordinates.boundsInRoot().roundToIntRect()
194 val expectedBounds = initialBounds.translate(IntOffset(50, 50))
195 assertThat(finalBounds).isEqualTo(expectedBounds)
196 }
197 }
198
199 @Test
200 fun whenContextMenuChanges_contextMenuUpdates() {
201 lateinit var coroutineScope: CoroutineScope
202 lateinit var provider: TextContextMenuProvider
203
204 fun contextMenuFunction(
205 tag: String
206 ): @Composable
207 (
208 session: TextContextMenuSession,
209 dataProvider: TextContextMenuDataProvider,
210 anchorLayoutCoordinates: () -> LayoutCoordinates,
211 ) -> Unit = { _, _, _ ->
212 Box(modifier = Modifier.background(Color.LightGray).size(50.dp).testTag(tag))
213 }
214
215 val tag1 = "ContextMenu1"
216 val tag2 = "ContextMenu2"
217 var contextMenu by mutableStateOf(contextMenuFunction(tag1))
218
219 rule.setContent {
220 coroutineScope = rememberCoroutineScope()
221 OuterBox {
222 ProvideBasicTextContextMenu(
223 modifier = Modifier.testTag(AnchorLayoutTag),
224 providableCompositionLocal = LocalTestContextMenuProvider,
225 contextMenu = contextMenu
226 ) {
227 provider = LocalTestContextMenuProvider.current!!
228 InnerBox()
229 }
230 }
231 }
232
233 val job1 = coroutineScope.launch { provider.showTextContextMenu(testDataProvider(1)) }
234 rule.waitForIdle()
235
236 rule.onNodeWithTag(tag1).assertIsDisplayed()
237 rule.onNodeWithTag(tag2).assertDoesNotExist()
238 assertThatJob(job1).isActive()
239
240 contextMenu = contextMenuFunction(tag2)
241 rule.waitForIdle()
242
243 rule.onNodeWithTag(tag1).assertDoesNotExist()
244 rule.onNodeWithTag(tag2).assertDoesNotExist()
245 assertThatJob(job1).isCompleted()
246
247 val job2 = coroutineScope.launch { provider.showTextContextMenu(testDataProvider(1)) }
248 rule.waitForIdle()
249
250 rule.onNodeWithTag(tag1).assertDoesNotExist()
251 rule.onNodeWithTag(tag2).assertIsDisplayed()
252 assertThatJob(job2).isActive()
253 }
254
255 /**
256 * @param outerContent Content that goes around the context menu provider, must call the content
257 * lambda exactly once
258 * @param innerContent Content that goes inside the context menu provider
259 * @param testBlock actions and assertions to run after the content is set
260 */
261 private fun runProviderTest(
262 outerContent: @Composable (content: @Composable () -> Unit) -> Unit = { content ->
263 OuterBox(content = content)
264 },
265 innerContent: @Composable () -> Unit = { InnerBox() },
266 testBlock: TestScope.() -> Unit,
267 ) {
268 val testScope = TestScope()
269 rule.setContent {
270 testScope.coroutineScope = rememberCoroutineScope()
271 outerContent {
272 if (testScope.enabled) {
273 ProvideTestBasicTextContextMenu(
274 onContextMenuComposition = { session, anchorLayoutCoordinates ->
275 testScope.session = session
276 testScope.anchorLayoutCoordinatesFunction = anchorLayoutCoordinates
277 }
278 ) {
279 testScope.provider = LocalTestContextMenuProvider.current
280 innerContent()
281 }
282 } else {
283 innerContent()
284 }
285 }
286 }
287
288 testScope.testBlock()
289 }
290
291 private inner class TestScope {
292 var anchorLayoutCoordinatesFunction: (() -> LayoutCoordinates)? = null
293 val anchorLayoutCoordinates: LayoutCoordinates
294 get() = assertNotNull(anchorLayoutCoordinatesFunction).invoke()
295
296 var coroutineScope: CoroutineScope? = null
297 var provider: TextContextMenuProvider? = null
298 var session: TextContextMenuSession? = null
299 var enabled by mutableStateOf(true)
300
301 fun launch(block: suspend CoroutineScope.() -> Unit): Job =
302 assertNotNull(coroutineScope).launch(block = block)
303
304 fun showTextContextMenu(dataProvider: TextContextMenuDataProvider): Job = launch {
305 assertNotNull(provider).showTextContextMenu(dataProvider)
306 }
307
308 fun assertContextMenuExistsWithNumbers(vararg itemNumbers: Int) {
309 rule.onNodeWithTag(ContextMenuTag).assertIsDisplayed()
310 itemNumbers.forEach { rule.onNodeWithTag("$it").assertIsDisplayed() }
311 }
312
313 fun assertContextMenuItemsWithNumbersDoNotExist(vararg itemNumbers: Int) {
314 itemNumbers.forEach { rule.onNodeWithTag("$it").assertDoesNotExist() }
315 }
316
317 fun assertContextMenuDoesNotExist() {
318 rule.onNodeWithTag(ContextMenuTag).assertDoesNotExist()
319 }
320 }
321 }
322
<lambda>null323 private fun <T : Any> assertNotNull(obj: T?): T = obj.also { assertThat(it).isNotNull() }!!
324
<lambda>null325 private val LocalTestContextMenuProvider = compositionLocalOf<TextContextMenuProvider?> { null }
326
327 private const val AnchorLayoutTag = "AnchorLayout"
328 private const val ContextMenuTag = "ContextMenu"
329
330 @Composable
331 @SuppressLint("ModifierParameter")
OuterBoxnull332 private fun OuterBox(modifier: Modifier = Modifier.fillMaxSize(), content: @Composable () -> Unit) {
333 Box(modifier, Alignment.Center) { content() }
334 }
335
336 @Composable
InnerBoxnull337 private fun InnerBox() {
338 Box(
339 Modifier.background(Color.LightGray.copy(alpha = 0.3f))
340 .sizeIn(minWidth = 200.dp, minHeight = 200.dp)
341 )
342 }
343
344 @Composable
ProvideTestBasicTextContextMenunull345 private fun ProvideTestBasicTextContextMenu(
346 onContextMenuComposition:
347 (
348 session: TextContextMenuSession?,
349 anchorLayoutCoordinates: () -> LayoutCoordinates,
350 ) -> Unit,
351 content: @Composable () -> Unit
352 ) {
353 ProvideBasicTextContextMenu(
354 modifier = Modifier.testTag(AnchorLayoutTag),
355 providableCompositionLocal = LocalTestContextMenuProvider,
356 contextMenu = { session, dataProvider, anchorLayoutCoordinates ->
357 onContextMenuComposition(session, anchorLayoutCoordinates)
358 TestContextMenu(session, dataProvider)
359 },
360 content = content,
361 )
362 }
363
364 @Composable
TestContextMenunull365 private fun TestContextMenu(
366 session: TextContextMenuSession,
367 dataProvider: TextContextMenuDataProvider,
368 ) {
369 Column(
370 verticalArrangement = Arrangement.spacedBy(4.dp),
371 modifier = Modifier.testTag(ContextMenuTag).background(Color.LightGray).padding(4.dp)
372 ) {
373 dataProvider.data().components.fastForEach {
374 when (it) {
375 is TextContextMenuItem ->
376 BasicText(
377 text = it.label,
378 modifier = Modifier.testTag(it.label).clickable { it.onClick(session) }
379 )
380 }
381 }
382 }
383 }
384
testDataProvidernull385 private fun testDataProvider(vararg itemNumbers: Int): TextContextMenuDataProvider =
386 object : TextContextMenuDataProvider {
387 override fun position(destinationCoordinates: LayoutCoordinates): Offset =
388 destinationCoordinates.size.toIntRect().center.toOffset()
389
390 override fun contentBounds(destinationCoordinates: LayoutCoordinates): Rect =
391 position(destinationCoordinates).let { Rect(it, it) }
392
393 override fun data(): TextContextMenuData =
394 TextContextMenuData(
395 itemNumbers.map {
396 TextContextMenuItem(
397 key = it,
398 label = "$it",
399 onClick = fun TextContextMenuSession.() {}
400 )
401 }
402 )
403 }
404