<lambda>null1 package com.android.systemui.statusbar.notification.stack
2 
3 import android.annotation.DimenRes
4 import android.content.pm.PackageManager
5 import android.platform.test.annotations.DisableFlags
6 import android.platform.test.annotations.EnableFlags
7 import android.widget.FrameLayout
8 import androidx.test.filters.SmallTest
9 import com.android.keyguard.BouncerPanelExpansionCalculator.aboutToShowBouncerProgress
10 import com.android.systemui.SysuiTestCase
11 import com.android.systemui.animation.ShadeInterpolation.getContentAlpha
12 import com.android.systemui.dump.DumpManager
13 import com.android.systemui.flags.FeatureFlags
14 import com.android.systemui.flags.FeatureFlagsClassic
15 import com.android.systemui.res.R
16 import com.android.systemui.shade.transition.LargeScreenShadeInterpolator
17 import com.android.systemui.statusbar.EmptyShadeView
18 import com.android.systemui.statusbar.NotificationShelf
19 import com.android.systemui.statusbar.StatusBarState
20 import com.android.systemui.statusbar.notification.RoundableState
21 import com.android.systemui.statusbar.notification.collection.NotificationEntry
22 import com.android.systemui.statusbar.notification.footer.ui.view.FooterView
23 import com.android.systemui.statusbar.notification.footer.ui.view.FooterView.FooterViewState
24 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
25 import com.android.systemui.statusbar.notification.row.ExpandableView
26 import com.android.systemui.statusbar.notification.shared.NotificationsImprovedHunAnimation
27 import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager
28 import com.android.systemui.statusbar.policy.AvalancheController
29 import com.android.systemui.util.mockito.mock
30 import com.google.common.truth.Expect
31 import com.google.common.truth.Truth.assertThat
32 import junit.framework.Assert.assertEquals
33 import junit.framework.Assert.assertFalse
34 import junit.framework.Assert.assertTrue
35 import kotlinx.coroutines.ExperimentalCoroutinesApi
36 import org.junit.Assume
37 import org.junit.Before
38 import org.junit.Rule
39 import org.junit.Test
40 import org.mockito.Mockito.any
41 import org.mockito.Mockito.eq
42 import org.mockito.Mockito.mock
43 import org.mockito.Mockito.verify
44 import org.mockito.Mockito.`when` as whenever
45 
46 @SmallTest
47 class StackScrollAlgorithmTest : SysuiTestCase() {
48 
49     @JvmField @Rule var expect: Expect = Expect.create()
50 
51     private val largeScreenShadeInterpolator = mock<LargeScreenShadeInterpolator>()
52     private val avalancheController = mock<AvalancheController>()
53 
54     private val hostView = FrameLayout(context)
55     private val stackScrollAlgorithm = StackScrollAlgorithm(context, hostView)
56     private val notificationRow = mock<ExpandableNotificationRow>()
57     private val notificationEntry = mock<NotificationEntry>()
58     private val dumpManager = mock<DumpManager>()
59     @OptIn(ExperimentalCoroutinesApi::class)
60     private val mStatusBarKeyguardViewManager = mock<StatusBarKeyguardViewManager>()
61     private val notificationShelf = mock<NotificationShelf>()
62     private val emptyShadeView =
63         EmptyShadeView(context, /* attrs= */ null).apply {
64             layout(/* l= */ 0, /* t= */ 0, /* r= */ 100, /* b= */ 100)
65         }
66     private val footerView = FooterView(context, /*attrs=*/ null)
67     @OptIn(ExperimentalCoroutinesApi::class)
68     private val ambientState =
69         AmbientState(
70             context,
71             dumpManager,
72             /* sectionProvider */ { _, _ -> false },
73             /* bypassController */ { false },
74             mStatusBarKeyguardViewManager,
75             largeScreenShadeInterpolator,
76             avalancheController
77         )
78 
79     private val testableResources = mContext.getOrCreateTestableResources()
80     private val featureFlags = mock<FeatureFlagsClassic>()
81     private val maxPanelHeight =
82         mContext.resources.displayMetrics.heightPixels -
83             px(R.dimen.notification_panel_margin_top) -
84             px(R.dimen.notification_panel_margin_bottom)
85 
86     private fun px(@DimenRes id: Int): Float =
87         testableResources.resources.getDimensionPixelSize(id).toFloat()
88 
89     private val bigGap = px(R.dimen.notification_section_divider_height)
90     private val smallGap = px(R.dimen.notification_section_divider_height_lockscreen)
91     private val scrimPadding = px(R.dimen.notification_side_paddings)
92 
93     @Before
94     fun setUp() {
95         Assume.assumeFalse(isTv())
96         mDependency.injectTestDependency(FeatureFlags::class.java, featureFlags)
97         whenever(notificationShelf.viewState).thenReturn(ExpandableViewState())
98         whenever(notificationRow.viewState).thenReturn(ExpandableViewState())
99         whenever(notificationRow.entry).thenReturn(notificationEntry)
100         whenever(notificationRow.roundableState)
101             .thenReturn(RoundableState(notificationRow, notificationRow, 0f))
102         ambientState.isSmallScreen = true
103 
104         hostView.addView(notificationRow)
105     }
106 
107     private fun isTv(): Boolean {
108         return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
109     }
110 
111     @Test
112     fun resetViewStates_defaultHun_yTranslationIsInset() {
113         whenever(notificationRow.isPinned).thenReturn(true)
114         whenever(notificationRow.isHeadsUp).thenReturn(true)
115         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
116     }
117 
118     @Test
119     fun resetViewStates_defaultHunWithStackMargin_changesHunYTranslation() {
120         whenever(notificationRow.isPinned).thenReturn(true)
121         whenever(notificationRow.isHeadsUp).thenReturn(true)
122         resetViewStates_stackMargin_changesHunYTranslation()
123     }
124 
125     @Test
126     fun resetViewStates_defaultHunWhenShadeIsOpening_yTranslationIsInset() {
127         whenever(notificationRow.isPinned).thenReturn(true)
128         whenever(notificationRow.isHeadsUp).thenReturn(true)
129 
130         // scroll the panel over the HUN inset
131         ambientState.stackY = stackScrollAlgorithm.mHeadsUpInset + bigGap
132 
133         // the HUN translation should be the panel scroll position + the scrim padding
134         resetViewStates_hunYTranslationIs(ambientState.stackY + scrimPadding)
135     }
136 
137     @Test
138     @DisableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
139     fun resetViewStates_hunAnimatingAway_yTranslationIsInset() {
140         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
141         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
142     }
143 
144     @Test
145     @DisableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
146     fun resetViewStates_hunAnimatingAway_StackMarginChangesHunYTranslation() {
147         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
148         resetViewStates_stackMargin_changesHunYTranslation()
149     }
150 
151     @Test
152     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
153     fun resetViewStates_defaultHun_newHeadsUpAnim_yTranslationIsInset() {
154         whenever(notificationRow.isPinned).thenReturn(true)
155         whenever(notificationRow.isHeadsUp).thenReturn(true)
156         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
157     }
158 
159     @Test
160     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
161     fun resetViewStates_defaultHunWithStackMargin_newHeadsUpAnim_changesHunYTranslation() {
162         whenever(notificationRow.isPinned).thenReturn(true)
163         whenever(notificationRow.isHeadsUp).thenReturn(true)
164         resetViewStates_stackMargin_changesHunYTranslation()
165     }
166 
167     @Test
168     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
169     fun resetViewStates_defaultHun_showingQS_newHeadsUpAnim_hunTranslatedToMax() {
170         // Given: the shade is open and scrolled to the bottom to show the QuickSettings
171         val maxHunTranslation = 2000f
172         ambientState.maxHeadsUpTranslation = maxHunTranslation
173         ambientState.setLayoutMinHeight(2500) // Mock the height of shade
174         ambientState.stackY = 2500f // Scroll over the max translation
175         stackScrollAlgorithm.setIsExpanded(true) // Mark the shade open
176         whenever(notificationRow.mustStayOnScreen()).thenReturn(true)
177         whenever(notificationRow.isHeadsUp).thenReturn(true)
178         whenever(notificationRow.isAboveShelf).thenReturn(true)
179 
180         resetViewStates_hunYTranslationIs(maxHunTranslation)
181     }
182 
183     @Test
184     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
185     fun resetViewStates_hunAnimatingAway_showingQS_newHeadsUpAnim_hunTranslatedToBottomOfScreen() {
186         // Given: the shade is open and scrolled to the bottom to show the QuickSettings
187         val bottomOfScreen = 2600f
188         val maxHunTranslation = 2000f
189         ambientState.maxHeadsUpTranslation = maxHunTranslation
190         ambientState.setLayoutMinHeight(2500) // Mock the height of shade
191         ambientState.stackY = 2500f // Scroll over the max translation
192         stackScrollAlgorithm.setIsExpanded(true) // Mark the shade open
193         stackScrollAlgorithm.setHeadsUpAppearHeightBottom(bottomOfScreen.toInt())
194         whenever(notificationRow.mustStayOnScreen()).thenReturn(true)
195         whenever(notificationRow.isHeadsUp).thenReturn(true)
196         whenever(notificationRow.isAboveShelf).thenReturn(true)
197         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
198 
199         resetViewStates_hunYTranslationIs(
200             expected = bottomOfScreen + stackScrollAlgorithm.mHeadsUpAppearStartAboveScreen
201         )
202     }
203 
204     @Test
205     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
206     fun resetViewStates_hunAnimatingAway_newHeadsUpAnim_hunTranslatedToTopOfScreen() {
207         val topMargin = 100f
208         ambientState.maxHeadsUpTranslation = 2000f
209         ambientState.stackTopMargin = topMargin.toInt()
210         whenever(notificationRow.intrinsicHeight).thenReturn(100)
211         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
212 
213         resetViewStates_hunYTranslationIs(
214             expected = -topMargin - stackScrollAlgorithm.mHeadsUpAppearStartAboveScreen
215         )
216     }
217 
218     @Test
219     fun resetViewStates_hunAnimatingAway_bottomNotClipped() {
220         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
221 
222         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
223 
224         assertThat(notificationRow.viewState.clipBottomAmount).isEqualTo(0)
225     }
226 
227     @Test
228     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
229     fun resetViewStates_hunAnimatingAwayWhileDozing_yTranslationIsInset() {
230         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
231 
232         ambientState.isDozing = true
233 
234         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
235     }
236 
237     @Test
238     @EnableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
239     fun resetViewStates_hunAnimatingAwayWhileDozing_hasStackMargin_changesHunYTranslation() {
240         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
241 
242         ambientState.isDozing = true
243 
244         resetViewStates_stackMargin_changesHunYTranslation()
245     }
246 
247     @Test
248     fun resetViewStates_hunsOverlapping_bottomHunClipped() {
249         val topHun = mockExpandableNotificationRow()
250         val bottomHun = mockExpandableNotificationRow()
251         whenever(topHun.isHeadsUp).thenReturn(true)
252         whenever(topHun.isPinned).thenReturn(true)
253         whenever(bottomHun.isHeadsUp).thenReturn(true)
254         whenever(bottomHun.isPinned).thenReturn(true)
255 
256         resetViewStates_hunsOverlapping_bottomHunClipped(topHun, bottomHun)
257     }
258 
259     @Test
260     @DisableFlags(NotificationsImprovedHunAnimation.FLAG_NAME)
261     fun resetViewStates_hunsOverlappingAndBottomHunAnimatingAway_bottomHunClipped() {
262         val topHun = mockExpandableNotificationRow()
263         val bottomHun = mockExpandableNotificationRow()
264         whenever(topHun.isHeadsUp).thenReturn(true)
265         whenever(topHun.isPinned).thenReturn(true)
266         whenever(bottomHun.isHeadsUpAnimatingAway).thenReturn(true)
267 
268         resetViewStates_hunsOverlapping_bottomHunClipped(topHun, bottomHun)
269     }
270 
271     @Test
272     fun resetViewStates_emptyShadeView_isCenteredVertically() {
273         stackScrollAlgorithm.initView(context)
274         hostView.removeAllViews()
275         hostView.addView(emptyShadeView)
276         ambientState.layoutMaxHeight = maxPanelHeight.toInt()
277 
278         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
279 
280         val marginBottom =
281             context.resources.getDimensionPixelSize(R.dimen.notification_panel_margin_bottom)
282         val fullHeight = ambientState.layoutMaxHeight + marginBottom - ambientState.stackY
283         val centeredY = ambientState.stackY + fullHeight / 2f - emptyShadeView.height / 2f
284         assertThat(emptyShadeView.viewState.yTranslation).isEqualTo(centeredY)
285     }
286 
287     @Test
288     fun resetViewStates_hunGoingToShade_viewBecomesOpaque() {
289         whenever(notificationRow.isAboveShelf).thenReturn(true)
290         ambientState.isShadeExpanded = true
291         ambientState.trackedHeadsUpRow = notificationRow
292         stackScrollAlgorithm.initView(context)
293 
294         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
295 
296         assertThat(notificationRow.viewState.alpha).isEqualTo(1f)
297     }
298 
299     @OptIn(ExperimentalCoroutinesApi::class)
300     @Test
301     fun resetViewStates_expansionChanging_notificationBecomesTransparent() {
302         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
303         resetViewStates_expansionChanging_notificationAlphaUpdated(
304             expansionFraction = 0.25f,
305             expectedAlpha = 0.0f
306         )
307     }
308 
309     @OptIn(ExperimentalCoroutinesApi::class)
310     @Test
311     fun resetViewStates_expansionChangingWhileBouncerInTransit_viewBecomesTransparent() {
312         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(true)
313         resetViewStates_expansionChanging_notificationAlphaUpdated(
314             expansionFraction = 0.85f,
315             expectedAlpha = 0.0f
316         )
317     }
318 
319     @OptIn(ExperimentalCoroutinesApi::class)
320     @Test
321     fun resetViewStates_expansionChanging_notificationAlphaUpdated() {
322         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
323         resetViewStates_expansionChanging_notificationAlphaUpdated(
324             expansionFraction = 0.6f,
325             expectedAlpha = getContentAlpha(0.6f)
326         )
327     }
328 
329     @OptIn(ExperimentalCoroutinesApi::class)
330     @Test
331     fun resetViewStates_largeScreen_expansionChanging_alphaUpdated_largeScreenValue() {
332         val expansionFraction = 0.6f
333         val surfaceAlpha = 123f
334         ambientState.isSmallScreen = false
335         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
336         whenever(largeScreenShadeInterpolator.getNotificationContentAlpha(expansionFraction))
337             .thenReturn(surfaceAlpha)
338 
339         resetViewStates_expansionChanging_notificationAlphaUpdated(
340             expansionFraction = expansionFraction,
341             expectedAlpha = surfaceAlpha,
342         )
343     }
344 
345     @OptIn(ExperimentalCoroutinesApi::class)
346     @Test
347     fun expansionChanging_largeScreen_bouncerInTransit_alphaUpdated_bouncerValues() {
348         ambientState.isSmallScreen = false
349         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(true)
350         resetViewStates_expansionChanging_notificationAlphaUpdated(
351             expansionFraction = 0.95f,
352             expectedAlpha = aboutToShowBouncerProgress(0.95f),
353         )
354     }
355 
356     @Test
357     fun resetViewStates_expansionChanging_shelfUpdated() {
358         ambientState.shelf = notificationShelf
359         ambientState.isExpansionChanging = true
360         ambientState.expansionFraction = 0.6f
361         stackScrollAlgorithm.initView(context)
362 
363         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
364 
365         verify(notificationShelf)
366             .updateState(/* algorithmState= */ any(), /* ambientState= */ eq(ambientState))
367     }
368 
369     @Test
370     fun resetViewStates_isOnKeyguard_viewBecomesTransparent() {
371         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
372         ambientState.hideAmount = 0.25f
373         whenever(notificationRow.isHeadsUpState).thenReturn(true)
374 
375         stackScrollAlgorithm.initView(context)
376 
377         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
378 
379         assertThat(notificationRow.viewState.alpha).isEqualTo(1f - ambientState.hideAmount)
380     }
381 
382     @Test
383     fun resetViewStates_isOnKeyguard_emptyShadeViewBecomesTransparent() {
384         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
385         ambientState.fractionToShade = 0.25f
386         stackScrollAlgorithm.initView(context)
387         hostView.removeAllViews()
388         hostView.addView(emptyShadeView)
389 
390         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
391 
392         val expected = getContentAlpha(ambientState.fractionToShade)
393         assertThat(emptyShadeView.viewState.alpha).isEqualTo(expected)
394     }
395 
396     @Test
397     fun resetViewStates_shadeCollapsed_emptyShadeViewBecomesTransparent() {
398         ambientState.expansionFraction = 0f
399         stackScrollAlgorithm.initView(context)
400         hostView.removeAllViews()
401         hostView.addView(emptyShadeView)
402 
403         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
404 
405         assertThat(emptyShadeView.viewState.alpha).isEqualTo(0f)
406     }
407 
408     @Test
409     fun resetViewStates_isOnKeyguard_emptyShadeViewBecomesOpaque() {
410         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
411         ambientState.fractionToShade = 0.25f
412         stackScrollAlgorithm.initView(context)
413         hostView.removeAllViews()
414         hostView.addView(emptyShadeView)
415 
416         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
417 
418         val expected = getContentAlpha(ambientState.fractionToShade)
419         assertThat(emptyShadeView.viewState.alpha).isEqualTo(expected)
420     }
421 
422     @Test
423     fun resetViewStates_hiddenShelf_allRowsBecomesTransparent() {
424         hostView.removeAllViews()
425         val row1 = mockExpandableNotificationRow()
426         hostView.addView(row1)
427         val row2 = mockExpandableNotificationRow()
428         hostView.addView(row2)
429 
430         whenever(row1.isHeadsUpState).thenReturn(true)
431         whenever(row2.isHeadsUpState).thenReturn(false)
432 
433         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
434         ambientState.hideAmount = 0.25f
435         ambientState.dozeAmount = 0.33f
436         notificationShelf.viewState.hidden = true
437         ambientState.shelf = notificationShelf
438         stackScrollAlgorithm.initView(context)
439 
440         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
441 
442         assertThat(row1.viewState.alpha).isEqualTo(1f - ambientState.hideAmount)
443         assertThat(row2.viewState.alpha).isEqualTo(1f - ambientState.dozeAmount)
444     }
445 
446     @Test
447     fun resetViewStates_hiddenShelf_shelfAlphaDoesNotChange() {
448         val expected = notificationShelf.viewState.alpha
449         notificationShelf.viewState.hidden = true
450         ambientState.shelf = notificationShelf
451         stackScrollAlgorithm.initView(context)
452 
453         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
454 
455         assertThat(notificationShelf.viewState.alpha).isEqualTo(expected)
456     }
457 
458     @Test
459     fun resetViewStates_shelfTopLessThanViewTop_hidesView() {
460         notificationRow.viewState.yTranslation = 10f
461         notificationShelf.viewState.yTranslation = 0.9f
462         notificationShelf.viewState.hidden = false
463         ambientState.shelf = notificationShelf
464         stackScrollAlgorithm.initView(context)
465 
466         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
467 
468         assertThat(notificationRow.viewState.alpha).isEqualTo(0f)
469     }
470 
471     @Test
472     fun resetViewStates_shelfTopGreaterOrEqualThanViewTop_viewAlphaDoesNotChange() {
473         val expected = notificationRow.viewState.alpha
474         notificationRow.viewState.yTranslation = 10f
475         notificationShelf.viewState.yTranslation = 10f
476         notificationShelf.viewState.hidden = false
477         ambientState.shelf = notificationShelf
478         stackScrollAlgorithm.initView(context)
479 
480         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
481 
482         assertThat(notificationRow.viewState.alpha).isEqualTo(expected)
483     }
484 
485     @Test
486     fun resetViewStates_noSpaceForFooter_footerHidden() {
487         ambientState.isShadeExpanded = true
488         ambientState.stackEndHeight = 0f // no space for the footer in the stack
489         hostView.addView(footerView)
490 
491         stackScrollAlgorithm.resetViewStates(ambientState, 0)
492 
493         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
494     }
495 
496     @Test
497     fun resetViewStates_clearAllInProgress_hasNonClearableRow_footerVisible() {
498         whenever(notificationRow.canViewBeCleared()).thenReturn(false)
499         ambientState.isClearAllInProgress = true
500         ambientState.isShadeExpanded = true
501         ambientState.stackEndHeight = maxPanelHeight // plenty space for the footer in the stack
502         hostView.addView(footerView)
503 
504         stackScrollAlgorithm.resetViewStates(ambientState, 0)
505 
506         assertThat(footerView.viewState.hidden).isFalse()
507         assertThat((footerView.viewState as FooterViewState).hideContent).isFalse()
508     }
509 
510     @Test
511     fun resetViewStates_clearAllInProgress_allRowsClearable_footerHidden() {
512         whenever(notificationRow.canViewBeCleared()).thenReturn(true)
513         ambientState.isClearAllInProgress = true
514         ambientState.isShadeExpanded = true
515         ambientState.stackEndHeight = maxPanelHeight // plenty space for the footer in the stack
516         hostView.addView(footerView)
517 
518         stackScrollAlgorithm.resetViewStates(ambientState, 0)
519 
520         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
521     }
522 
523     @Test
524     fun resetViewStates_clearAllInProgress_allRowsRemoved_emptyShade_footerHidden() {
525         ambientState.isClearAllInProgress = true
526         ambientState.isShadeExpanded = true
527         ambientState.stackEndHeight = maxPanelHeight // plenty space for the footer in the stack
528         hostView.removeAllViews() // remove all rows
529         hostView.addView(footerView)
530 
531         stackScrollAlgorithm.resetViewStates(ambientState, 0)
532 
533         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
534     }
535 
536     @Test
537     fun getGapForLocation_onLockscreen_returnsSmallGap() {
538         val gap =
539             stackScrollAlgorithm.getGapForLocation(
540                 /* fractionToShade= */ 0f,
541                 /* onKeyguard= */ true
542             )
543         assertThat(gap).isEqualTo(smallGap)
544     }
545 
546     @Test
547     fun getGapForLocation_goingToShade_interpolatesGap() {
548         val gap =
549             stackScrollAlgorithm.getGapForLocation(
550                 /* fractionToShade= */ 0.5f,
551                 /* onKeyguard= */ true
552             )
553         assertThat(gap).isEqualTo(smallGap * 0.5f + bigGap * 0.5f)
554     }
555 
556     @Test
557     fun getGapForLocation_notOnLockscreen_returnsBigGap() {
558         val gap =
559             stackScrollAlgorithm.getGapForLocation(
560                 /* fractionToShade= */ 0f,
561                 /* onKeyguard= */ false
562             )
563         assertThat(gap).isEqualTo(bigGap)
564     }
565 
566     @Test
567     fun updateViewWithShelf_viewAboveShelf_viewShown() {
568         val viewStart = 0f
569         val shelfStart = 1f
570 
571         val expandableView = mock(ExpandableView::class.java)
572         whenever(expandableView.isExpandAnimationRunning).thenReturn(false)
573         whenever(expandableView.hasExpandingChild()).thenReturn(false)
574 
575         val expandableViewState = ExpandableViewState()
576         expandableViewState.yTranslation = viewStart
577 
578         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
579         assertFalse(expandableViewState.hidden)
580     }
581 
582     @Test
583     fun updateViewWithShelf_viewBelowShelf_viewHidden() {
584         val shelfStart = 0f
585         val viewStart = 1f
586 
587         val expandableView = mock(ExpandableView::class.java)
588         whenever(expandableView.isExpandAnimationRunning).thenReturn(false)
589         whenever(expandableView.hasExpandingChild()).thenReturn(false)
590 
591         val expandableViewState = ExpandableViewState()
592         expandableViewState.yTranslation = viewStart
593 
594         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
595         assertTrue(expandableViewState.hidden)
596     }
597 
598     @Test
599     fun updateViewWithShelf_viewBelowShelfButIsExpanding_viewShown() {
600         val shelfStart = 0f
601         val viewStart = 1f
602 
603         val expandableView = mock(ExpandableView::class.java)
604         whenever(expandableView.isExpandAnimationRunning).thenReturn(true)
605         whenever(expandableView.hasExpandingChild()).thenReturn(true)
606 
607         val expandableViewState = ExpandableViewState()
608         expandableViewState.yTranslation = viewStart
609 
610         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
611         assertFalse(expandableViewState.hidden)
612     }
613 
614     @Test
615     fun maybeUpdateHeadsUpIsVisible_endVisible_true() {
616         val expandableViewState = ExpandableViewState()
617         expandableViewState.headsUpIsVisible = false
618 
619         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
620             expandableViewState,
621             /* isShadeExpanded= */ true,
622             /* mustStayOnScreen= */ true,
623             /* isViewEndVisible= */ true,
624             /* viewEnd= */ 0f,
625             /* maxHunY= */ 10f
626         )
627 
628         assertTrue(expandableViewState.headsUpIsVisible)
629     }
630 
631     @Test
632     fun maybeUpdateHeadsUpIsVisible_endHidden_false() {
633         val expandableViewState = ExpandableViewState()
634         expandableViewState.headsUpIsVisible = true
635 
636         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
637             expandableViewState,
638             /* isShadeExpanded= */ true,
639             /* mustStayOnScreen= */ true,
640             /* isViewEndVisible= */ true,
641             /* viewEnd= */ 10f,
642             /* maxHunY= */ 0f
643         )
644 
645         assertFalse(expandableViewState.headsUpIsVisible)
646     }
647 
648     @Test
649     fun maybeUpdateHeadsUpIsVisible_shadeClosed_noUpdate() {
650         val expandableViewState = ExpandableViewState()
651         expandableViewState.headsUpIsVisible = true
652 
653         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
654             expandableViewState,
655             /* isShadeExpanded= */ false,
656             /* mustStayOnScreen= */ true,
657             /* isViewEndVisible= */ true,
658             /* viewEnd= */ 10f,
659             /* maxHunY= */ 1f
660         )
661 
662         assertTrue(expandableViewState.headsUpIsVisible)
663     }
664 
665     @Test
666     fun maybeUpdateHeadsUpIsVisible_notHUN_noUpdate() {
667         val expandableViewState = ExpandableViewState()
668         expandableViewState.headsUpIsVisible = true
669 
670         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
671             expandableViewState,
672             /* isShadeExpanded= */ true,
673             /* mustStayOnScreen= */ false,
674             /* isViewEndVisible= */ true,
675             /* viewEnd= */ 10f,
676             /* maxHunY= */ 1f
677         )
678 
679         assertTrue(expandableViewState.headsUpIsVisible)
680     }
681 
682     @Test
683     fun maybeUpdateHeadsUpIsVisible_topHidden_noUpdate() {
684         val expandableViewState = ExpandableViewState()
685         expandableViewState.headsUpIsVisible = true
686 
687         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
688             expandableViewState,
689             /* isShadeExpanded= */ true,
690             /* mustStayOnScreen= */ true,
691             /* isViewEndVisible= */ false,
692             /* viewEnd= */ 10f,
693             /* maxHunY= */ 1f
694         )
695 
696         assertTrue(expandableViewState.headsUpIsVisible)
697     }
698 
699     @Test
700     fun clampHunToTop_viewYGreaterThanQqs_viewYUnchanged() {
701         val expandableViewState = ExpandableViewState()
702         expandableViewState.yTranslation = 50f
703 
704         stackScrollAlgorithm.clampHunToTop(
705             /* quickQsOffsetHeight= */ 10f,
706             /* stackTranslation= */ 0f,
707             /* collapsedHeight= */ 1f,
708             expandableViewState
709         )
710 
711         // qqs (10 + 0) < viewY (50)
712         assertEquals(50f, expandableViewState.yTranslation)
713     }
714 
715     @Test
716     fun clampHunToTop_viewYLessThanQqs_viewYChanged() {
717         val expandableViewState = ExpandableViewState()
718         expandableViewState.yTranslation = -10f
719 
720         stackScrollAlgorithm.clampHunToTop(
721             /* quickQsOffsetHeight= */ 10f,
722             /* stackTranslation= */ 0f,
723             /* collapsedHeight= */ 1f,
724             expandableViewState
725         )
726 
727         // qqs (10 + 0) > viewY (-10)
728         assertEquals(10f, expandableViewState.yTranslation)
729     }
730 
731     @Test
732     fun clampHunToTop_viewYFarAboveVisibleStack_heightCollapsed() {
733         val expandableViewState = ExpandableViewState()
734         expandableViewState.height = 20
735         expandableViewState.yTranslation = -100f
736 
737         stackScrollAlgorithm.clampHunToTop(
738             /* quickQsOffsetHeight= */ 10f,
739             /* stackTranslation= */ 0f,
740             /* collapsedHeight= */ 10f,
741             expandableViewState
742         )
743 
744         // newTranslation = max(10, -100) = 10
745         // distToRealY = 10 - (-100f) = 110
746         // height = max(20 - 110, 10f)
747         assertEquals(10, expandableViewState.height)
748     }
749 
750     @Test
751     fun clampHunToTop_viewYNearVisibleStack_heightTallerThanCollapsed() {
752         val expandableViewState = ExpandableViewState()
753         expandableViewState.height = 20
754         expandableViewState.yTranslation = 5f
755 
756         stackScrollAlgorithm.clampHunToTop(
757             /* quickQsOffsetHeight= */ 10f,
758             /* stackTranslation= */ 0f,
759             /* collapsedHeight= */ 10f,
760             expandableViewState
761         )
762 
763         // newTranslation = max(10, 5) = 10
764         // distToRealY = 10 - 5 = 5
765         // height = max(20 - 5, 10) = 15
766         assertEquals(15, expandableViewState.height)
767     }
768 
769     @Test
770     fun computeCornerRoundnessForPinnedHun_stackBelowScreen_round() {
771         val currentRoundness =
772             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
773                 /* hostViewHeight= */ 100f,
774                 /* stackY= */ 110f,
775                 /* viewMaxHeight= */ 20f,
776                 /* originalCornerRoundness= */ 0f
777             )
778         assertEquals(1f, currentRoundness)
779     }
780 
781     @Test
782     fun computeCornerRoundnessForPinnedHun_stackAboveScreenBelowPinPoint_halfRound() {
783         val currentRoundness =
784             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
785                 /* hostViewHeight= */ 100f,
786                 /* stackY= */ 90f,
787                 /* viewMaxHeight= */ 20f,
788                 /* originalCornerRoundness= */ 0f
789             )
790         assertEquals(0.5f, currentRoundness)
791     }
792 
793     @Test
794     fun computeCornerRoundnessForPinnedHun_stackAbovePinPoint_notRound() {
795         val currentRoundness =
796             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
797                 /* hostViewHeight= */ 100f,
798                 /* stackY= */ 0f,
799                 /* viewMaxHeight= */ 20f,
800                 /* originalCornerRoundness= */ 0f
801             )
802         assertEquals(0f, currentRoundness)
803     }
804 
805     @Test
806     fun computeCornerRoundnessForPinnedHun_originallyRoundAndStackAbovePinPoint_round() {
807         val currentRoundness =
808             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
809                 /* hostViewHeight= */ 100f,
810                 /* stackY= */ 0f,
811                 /* viewMaxHeight= */ 20f,
812                 /* originalCornerRoundness= */ 1f
813             )
814         assertEquals(1f, currentRoundness)
815     }
816 
817     @Test
818     fun shadeOpened_hunFullyOverlapsQqsPanel_hunShouldHaveFullShadow() {
819         // Given: shade is opened, yTranslation of HUN is 0,
820         // the height of HUN equals to the height of QQS Panel,
821         // and HUN fully overlaps with QQS Panel
822         ambientState.stackTranslation =
823             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
824         val childHunView =
825             createHunViewMock(isShadeOpen = true, fullyVisible = false, headerVisibleAmount = 1f)
826         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
827         algorithmState.visibleChildren.add(childHunView)
828 
829         // When: updateChildZValue() is called for the top HUN
830         stackScrollAlgorithm.updateChildZValue(
831             /* i= */ 0,
832             /* childrenOnTop= */ 0.0f,
833             /* StackScrollAlgorithmState= */ algorithmState,
834             /* ambientState= */ ambientState,
835             /* shouldElevateHun= */ true
836         )
837 
838         // Then: full shadow would be applied
839         assertEquals(px(R.dimen.heads_up_pinned_elevation), childHunView.viewState.zTranslation)
840     }
841 
842     @Test
843     fun shadeOpened_hunPartiallyOverlapsQQS_hunShouldHavePartialShadow() {
844         // Given: shade is opened, yTranslation of HUN is greater than 0,
845         // the height of HUN is equal to the height of QQS Panel,
846         // and HUN partially overlaps with QQS Panel
847         ambientState.stackTranslation =
848             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
849         val childHunView =
850             createHunViewMock(isShadeOpen = true, fullyVisible = false, headerVisibleAmount = 1f)
851         // Use half of the HUN's height as overlap
852         childHunView.viewState.yTranslation = (childHunView.viewState.height + 1 shr 1).toFloat()
853         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
854         algorithmState.visibleChildren.add(childHunView)
855 
856         // When: updateChildZValue() is called for the top HUN
857         stackScrollAlgorithm.updateChildZValue(
858             /* i= */ 0,
859             /* childrenOnTop= */ 0.0f,
860             /* StackScrollAlgorithmState= */ algorithmState,
861             /* ambientState= */ ambientState,
862             /* shouldElevateHun= */ true
863         )
864 
865         // Then: HUN should have shadow, but not as full size
866         assertThat(childHunView.viewState.zTranslation).isGreaterThan(0.0f)
867         assertThat(childHunView.viewState.zTranslation)
868             .isLessThan(px(R.dimen.heads_up_pinned_elevation))
869     }
870 
871     @Test
872     fun shadeOpened_hunDoesNotOverlapQQS_hunShouldHaveNoShadow() {
873         // Given: shade is opened, yTranslation of HUN is equal to QQS Panel's height,
874         // the height of HUN is equal to the height of QQS Panel,
875         // and HUN doesn't overlap with QQS Panel
876         ambientState.stackTranslation =
877             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
878         // Mock the height of shade
879         ambientState.setLayoutMinHeight(1000)
880         val childHunView =
881             createHunViewMock(isShadeOpen = true, fullyVisible = true, headerVisibleAmount = 1f)
882         // HUN doesn't overlap with QQS Panel
883         childHunView.viewState.yTranslation =
884             ambientState.topPadding + ambientState.stackTranslation
885         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
886         algorithmState.visibleChildren.add(childHunView)
887 
888         // When: updateChildZValue() is called for the top HUN
889         stackScrollAlgorithm.updateChildZValue(
890             /* i= */ 0,
891             /* childrenOnTop= */ 0.0f,
892             /* StackScrollAlgorithmState= */ algorithmState,
893             /* ambientState= */ ambientState,
894             /* shouldElevateHun= */ true
895         )
896 
897         // Then: HUN should not have shadow
898         assertEquals(0f, childHunView.viewState.zTranslation)
899     }
900 
901     @Test
902     fun shadeClosed_hunShouldHaveFullShadow() {
903         // Given: shade is closed, ambientState.stackTranslation == -ambientState.topPadding,
904         // the height of HUN is equal to the height of QQS Panel,
905         ambientState.stackTranslation = (-ambientState.topPadding).toFloat()
906         // Mock the height of shade
907         ambientState.setLayoutMinHeight(1000)
908         val childHunView =
909             createHunViewMock(isShadeOpen = false, fullyVisible = false, headerVisibleAmount = 0f)
910         childHunView.viewState.yTranslation = 0f
911         // Shade is closed, thus childHunView's headerVisibleAmount is 0
912         childHunView.headerVisibleAmount = 0f
913         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
914         algorithmState.visibleChildren.add(childHunView)
915 
916         // When: updateChildZValue() is called for the top HUN
917         stackScrollAlgorithm.updateChildZValue(
918             /* i= */ 0,
919             /* childrenOnTop= */ 0.0f,
920             /* StackScrollAlgorithmState= */ algorithmState,
921             /* ambientState= */ ambientState,
922             /* shouldElevateHun= */ true
923         )
924 
925         // Then: HUN should have full shadow
926         assertEquals(px(R.dimen.heads_up_pinned_elevation), childHunView.viewState.zTranslation)
927     }
928 
929     @Test
930     fun draggingHunToOpenShade_hunShouldHavePartialShadow() {
931         // Given: shade is closed when HUN pops up,
932         // now drags down the HUN to open shade
933         ambientState.stackTranslation = (-ambientState.topPadding).toFloat()
934         // Mock the height of shade
935         ambientState.setLayoutMinHeight(1000)
936         val childHunView =
937             createHunViewMock(isShadeOpen = false, fullyVisible = false, headerVisibleAmount = 0.5f)
938         childHunView.viewState.yTranslation = 0f
939         // Shade is being opened, thus childHunView's headerVisibleAmount is between 0 and 1
940         // use 0.5 as headerVisibleAmount here
941         childHunView.headerVisibleAmount = 0.5f
942         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
943         algorithmState.visibleChildren.add(childHunView)
944 
945         // When: updateChildZValue() is called for the top HUN
946         stackScrollAlgorithm.updateChildZValue(
947             /* i= */ 0,
948             /* childrenOnTop= */ 0.0f,
949             /* StackScrollAlgorithmState= */ algorithmState,
950             /* ambientState= */ ambientState,
951             /* shouldElevateHun= */ true
952         )
953 
954         // Then: HUN should have shadow, but not as full size
955         assertThat(childHunView.viewState.zTranslation).isGreaterThan(0.0f)
956         assertThat(childHunView.viewState.zTranslation)
957             .isLessThan(px(R.dimen.heads_up_pinned_elevation))
958     }
959 
960     @Test
961     fun aodToLockScreen_hasPulsingNotification_pulsingNotificationRowDoesNotChange() {
962         // Given: Before AOD to LockScreen, there was a pulsing notification
963         val pulsingNotificationView = createPulsingViewMock()
964         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
965         algorithmState.visibleChildren.add(pulsingNotificationView)
966         ambientState.setPulsingRow(pulsingNotificationView)
967 
968         // When: during AOD to LockScreen, any dozeAmount between (0, 1.0) is equivalent as a middle
969         // stage; here we use 0.5 for testing.
970         // stackScrollAlgorithm.updatePulsingStates is called
971         ambientState.dozeAmount = 0.5f
972         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
973 
974         // Then: ambientState.pulsingRow should still be pulsingNotificationView
975         assertTrue(ambientState.isPulsingRow(pulsingNotificationView))
976     }
977 
978     @Test
979     fun deviceOnAod_hasPulsingNotification_recordPulsingNotificationRow() {
980         // Given: Device is on AOD, there is a pulsing notification
981         // ambientState.pulsingRow is null before stackScrollAlgorithm.updatePulsingStates
982         ambientState.dozeAmount = 1.0f
983         val pulsingNotificationView = createPulsingViewMock()
984         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
985         algorithmState.visibleChildren.add(pulsingNotificationView)
986         ambientState.setPulsingRow(null)
987 
988         // When: stackScrollAlgorithm.updatePulsingStates is called
989         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
990 
991         // Then: ambientState.pulsingRow should record the pulsingNotificationView
992         assertTrue(ambientState.isPulsingRow(pulsingNotificationView))
993     }
994 
995     @Test
996     fun deviceOnLockScreen_hasPulsingNotificationBefore_clearPulsingNotificationRowRecord() {
997         // Given: Device finished AOD to LockScreen, there was a pulsing notification, and
998         // ambientState.pulsingRow was not null before AOD to LockScreen
999         // pulsingNotificationView.showingPulsing() returns false since the device is on LockScreen
1000         ambientState.dozeAmount = 0.0f
1001         val pulsingNotificationView = createPulsingViewMock()
1002         whenever(pulsingNotificationView.showingPulsing()).thenReturn(false)
1003         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1004         algorithmState.visibleChildren.add(pulsingNotificationView)
1005         ambientState.setPulsingRow(pulsingNotificationView)
1006 
1007         // When: stackScrollAlgorithm.updatePulsingStates is called
1008         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
1009 
1010         // Then: ambientState.pulsingRow should be null
1011         assertTrue(ambientState.isPulsingRow(null))
1012     }
1013 
1014     @Test
1015     fun aodToLockScreen_hasPulsingNotification_pulsingNotificationRowShowAtFullHeight() {
1016         // Given: Before AOD to LockScreen, there was a pulsing notification
1017         val pulsingNotificationView = createPulsingViewMock()
1018         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1019         algorithmState.visibleChildren.add(pulsingNotificationView)
1020         ambientState.setPulsingRow(pulsingNotificationView)
1021 
1022         // When: during AOD to LockScreen, any dozeAmount between (0, 1.0) is equivalent as a middle
1023         // stage; here we use 0.5 for testing. The expansionFraction is also 0.5.
1024         // stackScrollAlgorithm.resetViewStates is called.
1025         ambientState.dozeAmount = 0.5f
1026         setExpansionFractionWithoutShelfDuringAodToLockScreen(
1027             ambientState,
1028             algorithmState,
1029             fraction = 0.5f
1030         )
1031         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1032 
1033         // Then: pulsingNotificationView should show at full height
1034         assertEquals(
1035             stackScrollAlgorithm.getMaxAllowedChildHeight(pulsingNotificationView),
1036             pulsingNotificationView.viewState.height
1037         )
1038 
1039         // After: reset dozeAmount and expansionFraction
1040         ambientState.dozeAmount = 0f
1041         setExpansionFractionWithoutShelfDuringAodToLockScreen(
1042             ambientState,
1043             algorithmState,
1044             fraction = 1f
1045         )
1046     }
1047 
1048     // region shouldPinHunToBottomOfExpandedQs
1049     @Test
1050     fun shouldHunBeVisibleWhenScrolled_mustStayOnScreenFalse_false() {
1051         assertThat(
1052                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1053                     /* mustStayOnScreen= */ false,
1054                     /* headsUpIsVisible= */ false,
1055                     /* showingPulsing= */ false,
1056                     /* isOnKeyguard=*/ false,
1057                     /*headsUpOnKeyguard=*/ false
1058                 )
1059             )
1060             .isFalse()
1061     }
1062 
1063     @Test
1064     fun shouldPinHunToBottomOfExpandedQs_headsUpIsVisible_false() {
1065         assertThat(
1066                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1067                     /* mustStayOnScreen= */ true,
1068                     /* headsUpIsVisible= */ true,
1069                     /* showingPulsing= */ false,
1070                     /* isOnKeyguard=*/ false,
1071                     /*headsUpOnKeyguard=*/ false
1072                 )
1073             )
1074             .isFalse()
1075     }
1076 
1077     @Test
1078     fun shouldHunBeVisibleWhenScrolled_showingPulsing_false() {
1079         assertThat(
1080                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1081                     /* mustStayOnScreen= */ true,
1082                     /* headsUpIsVisible= */ false,
1083                     /* showingPulsing= */ true,
1084                     /* isOnKeyguard=*/ false,
1085                     /* headsUpOnKeyguard= */ false
1086                 )
1087             )
1088             .isFalse()
1089     }
1090 
1091     @Test
1092     fun shouldHunBeVisibleWhenScrolled_isOnKeyguard_false() {
1093         assertThat(
1094                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1095                     /* mustStayOnScreen= */ true,
1096                     /* headsUpIsVisible= */ false,
1097                     /* showingPulsing= */ false,
1098                     /* isOnKeyguard=*/ true,
1099                     /* headsUpOnKeyguard= */ false
1100                 )
1101             )
1102             .isFalse()
1103     }
1104 
1105     @Test
1106     fun shouldHunBeVisibleWhenScrolled_isNotOnKeyguard_true() {
1107         assertThat(
1108                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1109                     /* mustStayOnScreen= */ true,
1110                     /* headsUpIsVisible= */ false,
1111                     /* showingPulsing= */ false,
1112                     /* isOnKeyguard=*/ false,
1113                     /* headsUpOnKeyguard= */ false
1114                 )
1115             )
1116             .isTrue()
1117     }
1118 
1119     @Test
1120     fun shouldHunBeVisibleWhenScrolled_headsUpOnKeyguard_true() {
1121         assertThat(
1122                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1123                     /* mustStayOnScreen= */ true,
1124                     /* headsUpIsVisible= */ false,
1125                     /* showingPulsing= */ false,
1126                     /* isOnKeyguard=*/ true,
1127                     /* headsUpOnKeyguard= */ true
1128                 )
1129             )
1130             .isTrue()
1131     }
1132 
1133     @Test
1134     fun shouldHunAppearFromBottom_hunAtMaxHunTranslation() {
1135         ambientState.maxHeadsUpTranslation = 400f
1136         val viewState =
1137             ExpandableViewState().apply {
1138                 height = 100
1139                 yTranslation = ambientState.maxHeadsUpTranslation - height // move it to the max
1140             }
1141 
1142         assertTrue(stackScrollAlgorithm.shouldHunAppearFromBottom(ambientState, viewState))
1143     }
1144 
1145     @Test
1146     fun shouldHunAppearFromBottom_hunBelowMaxHunTranslation() {
1147         ambientState.maxHeadsUpTranslation = 400f
1148         val viewState =
1149             ExpandableViewState().apply {
1150                 height = 100
1151                 yTranslation =
1152                     ambientState.maxHeadsUpTranslation - height - 1 // move it below the max
1153             }
1154 
1155         assertFalse(stackScrollAlgorithm.shouldHunAppearFromBottom(ambientState, viewState))
1156     }
1157     // endregion
1158 
1159     private fun createHunViewMock(
1160         isShadeOpen: Boolean,
1161         fullyVisible: Boolean,
1162         headerVisibleAmount: Float
1163     ) =
1164         mock<ExpandableNotificationRow>().apply {
1165             val childViewStateMock = createHunChildViewState(isShadeOpen, fullyVisible)
1166             whenever(this.viewState).thenReturn(childViewStateMock)
1167 
1168             whenever(this.mustStayOnScreen()).thenReturn(true)
1169             whenever(this.headerVisibleAmount).thenReturn(headerVisibleAmount)
1170         }
1171 
1172     private fun createHunChildViewState(isShadeOpen: Boolean, fullyVisible: Boolean) =
1173         ExpandableViewState().apply {
1174             // Mock the HUN's height with ambientState.topPadding +
1175             // ambientState.stackTranslation
1176             height = (ambientState.topPadding + ambientState.stackTranslation).toInt()
1177             if (isShadeOpen && fullyVisible) {
1178                 yTranslation = ambientState.topPadding + ambientState.stackTranslation
1179             } else {
1180                 yTranslation = 0f
1181             }
1182             headsUpIsVisible = fullyVisible
1183         }
1184 
1185     private fun createPulsingViewMock() =
1186         mock<ExpandableNotificationRow>().apply {
1187             whenever(this.viewState).thenReturn(ExpandableViewState())
1188             whenever(this.showingPulsing()).thenReturn(true)
1189         }
1190 
1191     private fun setExpansionFractionWithoutShelfDuringAodToLockScreen(
1192         ambientState: AmbientState,
1193         algorithmState: StackScrollAlgorithm.StackScrollAlgorithmState,
1194         fraction: Float
1195     ) {
1196         // showingShelf: false
1197         algorithmState.firstViewInShelf = null
1198         // scrimPadding: 0, because device is on lock screen
1199         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
1200         ambientState.dozeAmount = 0.0f
1201         // set stackEndHeight and stackHeight
1202         // ExpansionFractionWithoutShelf == stackHeight / stackEndHeight
1203         ambientState.stackEndHeight = 100f
1204         ambientState.stackHeight = ambientState.stackEndHeight * fraction
1205     }
1206 
1207     private fun resetViewStates_hunYTranslationIs(expected: Float) {
1208         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1209 
1210         assertThat(notificationRow.viewState.yTranslation).isEqualTo(expected)
1211     }
1212 
1213     private fun resetViewStates_stackMargin_changesHunYTranslation() {
1214         val stackTopMargin = bigGap.toInt() // a gap smaller than the headsUpInset
1215         val headsUpTranslationY = stackScrollAlgorithm.mHeadsUpInset - stackTopMargin
1216 
1217         // we need the shelf to mock the real-life behaviour of StackScrollAlgorithm#updateChild
1218         ambientState.shelf = notificationShelf
1219 
1220         // split shade case with top margin introduced by shade's status bar
1221         ambientState.stackTopMargin = stackTopMargin
1222         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1223 
1224         // heads up translation should be decreased by the top margin
1225         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTranslationY)
1226     }
1227 
1228     private fun resetViewStates_hunsOverlapping_bottomHunClipped(
1229         topHun: ExpandableNotificationRow,
1230         bottomHun: ExpandableNotificationRow
1231     ) {
1232         val topHunHeight =
1233             mContext.resources.getDimensionPixelSize(R.dimen.notification_content_min_height)
1234         val bottomHunHeight =
1235             mContext.resources.getDimensionPixelSize(R.dimen.notification_max_heads_up_height)
1236         whenever(topHun.intrinsicHeight).thenReturn(topHunHeight)
1237         whenever(bottomHun.intrinsicHeight).thenReturn(bottomHunHeight)
1238 
1239         // we need the shelf to mock the real-life behaviour of StackScrollAlgorithm#updateChild
1240         ambientState.shelf = notificationShelf
1241 
1242         // add two overlapping HUNs
1243         hostView.removeAllViews()
1244         hostView.addView(topHun)
1245         hostView.addView(bottomHun)
1246 
1247         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
1248 
1249         // the height shouldn't change
1250         assertThat(topHun.viewState.height).isEqualTo(topHunHeight)
1251         assertThat(bottomHun.viewState.height).isEqualTo(bottomHunHeight)
1252         // the HUN at the bottom should be clipped
1253         assertThat(topHun.viewState.clipBottomAmount).isEqualTo(0)
1254         assertThat(bottomHun.viewState.clipBottomAmount).isEqualTo(bottomHunHeight - topHunHeight)
1255     }
1256 
1257     private fun resetViewStates_expansionChanging_notificationAlphaUpdated(
1258         expansionFraction: Float,
1259         expectedAlpha: Float,
1260     ) {
1261         ambientState.isExpansionChanging = true
1262         ambientState.expansionFraction = expansionFraction
1263         stackScrollAlgorithm.initView(context)
1264 
1265         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
1266 
1267         expect.that(notificationRow.viewState.alpha).isEqualTo(expectedAlpha)
1268     }
1269 }
1270 
mockExpandableNotificationRownull1271 private fun mockExpandableNotificationRow(): ExpandableNotificationRow {
1272     return mock(ExpandableNotificationRow::class.java).apply {
1273         whenever(viewState).thenReturn(ExpandableViewState())
1274     }
1275 }
1276