1+ package com.mapbox.maps.extension.compose.annotation
2+
3+ import android.view.ViewPropertyAnimator
4+ import androidx.compose.ui.platform.ComposeView
5+ import androidx.test.core.app.ApplicationProvider
6+ import com.mapbox.maps.extension.compose.R
7+ import com.mapbox.maps.extension.compose.ShadowLogConfiguration
8+ import com.mapbox.maps.extension.compose.internal.MapNode
9+ import com.mapbox.maps.viewannotation.OnViewAnnotationUpdatedListener
10+ import com.mapbox.maps.viewannotation.ViewAnnotationManager
11+ import io.mockk.CapturingSlot
12+ import io.mockk.every
13+ import io.mockk.mockk
14+ import io.mockk.slot
15+ import io.mockk.spyk
16+ import io.mockk.unmockkAll
17+ import io.mockk.verify
18+ import org.junit.After
19+ import org.junit.Assert.assertNull
20+ import org.junit.Before
21+ import org.junit.Test
22+ import org.junit.runner.RunWith
23+ import org.robolectric.RobolectricTestRunner
24+ import org.robolectric.annotation.Config
25+
26+ @Config(shadows = [ShadowLogConfiguration ::class ])
27+ @RunWith(RobolectricTestRunner ::class )
28+ internal class ViewAnnotationNodeTest {
29+
30+ private lateinit var viewAnnotationManager: ViewAnnotationManager
31+ private lateinit var composeView: ComposeView
32+ private lateinit var parentNode: MapNode
33+
34+ @Before
35+ fun setup () {
36+ viewAnnotationManager = mockk(relaxed = true )
37+ composeView = spyk(ComposeView (ApplicationProvider .getApplicationContext()))
38+ parentNode = mockk(relaxed = true )
39+ }
40+
41+ @After
42+ fun tearDown () {
43+ unmockkAll()
44+ }
45+
46+ @Test
47+ fun `onRemoved disposes composition and removes view annotation` () {
48+ val node = ViewAnnotationNode (
49+ viewAnnotationManager = viewAnnotationManager,
50+ view = composeView,
51+ updatedListener = null ,
52+ )
53+
54+ node.onRemoved(parentNode)
55+
56+ verify { composeView.disposeComposition() }
57+ verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
58+ }
59+
60+ @Test
61+ fun `onRemoved nulls updatedListener` () {
62+ val listener = mockk<OnViewAnnotationUpdatedListener >()
63+ val node = ViewAnnotationNode (
64+ viewAnnotationManager = viewAnnotationManager,
65+ view = composeView,
66+ updatedListener = listener,
67+ )
68+
69+ node.onAttached(parentNode)
70+ node.onRemoved(parentNode)
71+
72+ assertNull(node.updatedListener)
73+ }
74+
75+ @Test
76+ fun `onRemoved removes internal update listener when observing` () {
77+ val listener = mockk<OnViewAnnotationUpdatedListener >()
78+ val node = ViewAnnotationNode (
79+ viewAnnotationManager = viewAnnotationManager,
80+ view = composeView,
81+ updatedListener = listener,
82+ )
83+
84+ node.onAttached(parentNode)
85+ node.onRemoved(parentNode)
86+
87+ verify { viewAnnotationManager.removeOnViewAnnotationUpdatedListener(any()) }
88+ }
89+
90+ @Test
91+ fun `onRemoved skips removing internal update listener when not observing` () {
92+ val node = ViewAnnotationNode (
93+ viewAnnotationManager = viewAnnotationManager,
94+ view = composeView,
95+ updatedListener = null ,
96+ )
97+
98+ node.onRemoved(parentNode)
99+
100+ verify(exactly = 0 ) { viewAnnotationManager.removeOnViewAnnotationUpdatedListener(any()) }
101+ }
102+
103+ @Test
104+ fun `onClear disposes composition and removes view annotation` () {
105+ val node = ViewAnnotationNode (
106+ viewAnnotationManager = viewAnnotationManager,
107+ view = composeView,
108+ updatedListener = null ,
109+ )
110+
111+ node.onClear()
112+
113+ verify { composeView.disposeComposition() }
114+ verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
115+ }
116+
117+ @Test
118+ fun `onRemoved with disappear animation defers cleanup` () {
119+ setupDisappearAnimation()
120+
121+ val node = ViewAnnotationNode (
122+ viewAnnotationManager = viewAnnotationManager,
123+ view = composeView,
124+ updatedListener = null ,
125+ )
126+
127+ node.onRemoved(parentNode)
128+
129+ verify(exactly = 0 ) { viewAnnotationManager.removeViewAnnotation(view = composeView) }
130+ verify(exactly = 0 ) { composeView.disposeComposition() }
131+ }
132+
133+ @Test
134+ fun `onRemoved with disappear animation cleans up after animation ends` () {
135+ val endActionSlot = slot<Runnable >()
136+ setupDisappearAnimation(endActionSlot)
137+
138+ val node = ViewAnnotationNode (
139+ viewAnnotationManager = viewAnnotationManager,
140+ view = composeView,
141+ updatedListener = null ,
142+ )
143+
144+ node.onRemoved(parentNode)
145+ endActionSlot.captured.run ()
146+
147+ verify { viewAnnotationManager.removeViewAnnotation(view = composeView) }
148+ verify { composeView.disposeComposition() }
149+ }
150+
151+ private fun setupDisappearAnimation (
152+ endActionSlot : CapturingSlot <Runnable >? = null,
153+ ): ViewPropertyAnimator {
154+ val animationConfig = MarkerAnimationConfig (
155+ listOf (MarkerAnimationEffect .Effect .Fade (from = 1f , to = 0f ))
156+ )
157+ composeView.setTag(R .id.markerDisappearAnimation, animationConfig)
158+
159+ val animator = mockk<ViewPropertyAnimator >(relaxed = true )
160+ every { composeView.animate() } returns animator
161+ every { animator.alpha(any()) } returns animator
162+ every { animator.setDuration(any()) } returns animator
163+ if (endActionSlot != null ) {
164+ every { animator.withEndAction(capture(endActionSlot)) } returns animator
165+ }
166+ return animator
167+ }
168+ }
0 commit comments